Claude Code subagent imported from AI-WorkDeck/aiworkdeck (
.claude/agents/licensing-billing.md). Copyright stays with the author.
授权与计费 领域地图
设置账户状态(dev-board#1415,2026-10-21)
AdminPane 的账户状态区分 loading / ready / error:只有成功取得明确的 connected:false 才显示登录入口;请求失败保留已确认身份并给重试,不当作退出登录。并发状态请求按代次接收,旧未连接回包不能盖过登录后的新状态。账户/钱包块必须显式按 connected 显示,不能用紧跟手机同步 v-if=isDesktop 的 v-else(这会在桌面隐藏整个账户块)。
验证:tests/account/admin-account-state.test.mjs 真模板 SSR 与加载方法;admin-account-ui.mjs 实际设置路由、合成 API 的初始等待/连接/账户事件失败/重试/退出。用户截图发生时的请求失败原因无法仅凭截图还原;模板错误与失败误判路径各自有回归,不混为一个根因。
受限网络兼容验证(dev-board#1330,2026-10-17)
国内阿里云/国际 Turnstile 浏览器控件被网络拦截时,桌面登录卡与官网注册/重置表单可手动选「使用兼容验证」。短信本来就由官网服务器发送;兼容入口替换的是人机验证,不是短信供应商。官网 POST /api/auth/captcha-challenge 接收 {phone|email,purpose:login|reset},返回 {id,image,expiresAt};图片为服务端栅格化的 PNG data URL,无答案明文。桌面 /api/account/captcha-challenge 经机器账户闸代理;插件匿名桥 /api/auth/account-login/captcha-challenge 受桥接开关与独立图片签发限频,不能扣短信发送桶,也不信任客户端提供的 IP 字段。
发送仍用既有 captchaToken,兼容格式 local:<id>:<answer>。官网 SQLite captcha_challenge 存哈希、规范联系方式、用途、来源 IP 与有效期;3 分钟失效,BEGIN IMMEDIATE 原子核销,每次尝试(含错误/绑定不符)作废。挑战签发独立限制账号/IP/全局频率,发送沿用短信/邮件冷却、日配额与全局熔断。兼容验证不能借缺 token、SDK失败、主供应商未配置直接放行;不是第三方行为风控的等价替代。Java 图片签发与发送走相同出站路径,官网来源 IP 仍沿用既有可信反代边界,未添加任意 XFF 转发。
错误分层:组件加载失败、captcha_failed、账户服务网络失败、短信/邮件发送失败;后者保留上游已脱敏的冷却/日配额具体提示。错误联系方式是 invalid_phone / invalid_email,不得显示为验证码错误。错误/过期图需换一张;切联系方式、用途或站点清掉旧图。
验证:官网隔离空目录 scripts/verify-captcha-challenge.mts(cn/intl,所有外发拦截);主仓 frontend/tests/account/compatible-{desktop,website,addin}-ui.mjs(合成 API + 主动阻断第三方 + 实际 PNG);AccountServiceTest、AwdkLoginServiceTest、AuthControllerHardeningTest。不代表生产短信、律所网络、正式安装包或真实反代 IP 配置已实测。
统一密码登录(dev-board#1299,2026-10-15)
此节取代下文历史条目的「验证码即登录」「桌面无密码入口」。官网是唯一密码权威;桌面
AccountLoginDialog.vue 默认手机号(cn)/邮箱(intl)+ 密码,首次使用/存量无密码用户走
验证码→创建并确认密码→密码登录。忘记密码走独立 reset 场景;密码保留首尾空白,8–72 字符。
未设置密码时官网 exchange-key 只返回 {passwordSetupRequired:true,setupToken,expiresAt,account},
Java PasswordSetupRequired 将它作为 code=0/data 受限挑战返回,不执行 connect、账户缓存清理、
identitySync、平台Key申请或令牌签发。完成 setup 后再单独 exchange-key/password 登录。
桌面端点:POST /api/account/password/{setup,reset/send-code,reset},受既有机器账户权限闸;
插件/移动匿名桥:POST /api/auth/account-login/password/{setup,reset/send-code,reset},沿用桥接
开关与 IP 限速。它们仅代理官网同名 /api/auth/password/*;不在 Java 库保存官网密码。
AccountLoginExchange 将 password_login_required/password_setup_required 等业务态保留 reason;
不将它们计为错误密码导致锁定。setup/reset 本身只返回 ok,不能当作登录成功。
桌面与插件切模式/站点清空密码、确认密码、验证码及 setupToken;验证码控件节点保持挂载,
仅首次验证/重置表单显示。两份密码输入都不得 trim。已有 TOTP 与自建服务器 /api/auth/login
二次验证链不变。官网重置失效范围不能冒充已撤销所有 Java awdt_;旧设备令牌撤销仍属独立机制。
验证:AccountServiceTest、AccountControllerLoginSyncTest、AuthControllerHardeningTest、
MobileApiContractTest;前端 tests/account/password-{login,desktop}-ui.mjs 用实际路由及合成API,
截图在显式配置的临时目录。浏览器 UI 不等于正式 Electron、Word/WPS 或生产短信实测。
职责边界:谁被允许用、用多少、钱怎么记。包括桌面解锁门、账户连接、entitlement 框架、 本地 SKU 的免费额度执行、平台 AI 通道的取 key 与对账、广场付费项闸门、单机免登的身份解析与准入闸。 不含:广场的页面形态与安装链路本身(plugin-marketplace)、AI 编排与供应商路由的其余部分(ai-chat)、 剪贴板/缓存区/存储位置这些功能自身的实现(utility-tools)、协作与团队案件库(version-control)。 这四个领域各自的文档里都有本领域的落点,跨领域任务两份都读。
商业化改造(2026-08-05/06)由六个已合并 PR 落地:PR-A #247(去登录 + 解锁门)、PR-B #248
(entitlement + 账户连接 + 平台 AI 通道)、PR-C #249(剪贴板/缓存区免费额度 + 自选存储位置)、
#250(身份解析修正)、PR-D #251(广场付费项)、PR-E #252(协作 UX,本领域只涉术语)。
总 Spec 见 docs/superpowers/specs/2026-08-05-commercialization-redesign.md,
Spec 与实现有偏差时以代码为准,偏差逐条记在该文件末尾「实现与 Spec 的偏差记录」一节。
Pro 订阅与充值分账(dev-board#1187,2026-10-11)
官网仍是余额与权益权威源,149 元购买一次 30 天,不自动续费。订阅服务额度和充值 Credits 分账;2026-10-11 用户追加要求按 ¥149 对应 745 Credits 同步更新周/月限额,当前采用 745 Credits / 30 天、200 Credits / 7 天(周 200 沿用此前对应方案;尚未部署)。周/月窗口从订阅周期开始时刻计算。官网 subscription 快照由账户余额和平台用量接口透传,桌面 AdminPane 展示两层额度、重置时间及充值余额续用开关状态;开关在官网账户页修改。
EntitlementService 缓存逐项到期时间与账户指纹,离线宽限不能延长订阅权益,单独购买的永久权益仍保留。Pro 派生 plan.pro、clipboard.unlimited、stage.unlimited、cloud.storage.3gb;旧缓存的 Pro 无到期时间时须刷新。
CloudStorageQuotaService 通过当前云用户的 MobileBilling 绑定查询权益,不能借机器管理员账户。官方 cloud profile 开启普通项目写入配额校验;desktop/self-host 默认关闭。Pro 为 3GiB 持久空间,免费账户无持久空间;到期已有文件仍能读、删。云写入以所属用户行锁串行计量,复制、编辑、AI 产物和模板都计入;优先核算实际磁盘字节,回收站中仍在磁盘上的文件继续占用。所有账户临时中转池为 200MiB,同时包含手机上传及桌面取件暂存,ACK 后释放,不是每月流量。
验证入口:EntitlementServiceTest、HttpMobileBillingClientTest、CloudStorageQuotaServiceTest、ProjectFileServiceCloudQuotaTest、MobileRelayStoreServiceTest、MobileTransferServiceTest;官网独立空目录脚本 verify-subscription-allowance.mts。跨仓细节及经营测算见 docs/handoffs/2026-10-11-subscription-project-unification.md。合并不等于部署。
试用缓存与回合归属(dev-board#1166,2026-10-10)
GET /api/trial/balance 以账户站 /api/account/trial 为权威;断网时只展示本机 trial_balance.account_fingerprint 与当前账户一致的已确认缓存。无行、旧版无归属行、另一账户行均返回未知,不推定 14 天 / 80 次;官网明确未发放时清掉同账户旧行。归属与完整 account_snapshot 两列可空,由既有 Hibernate update 升级;离线保留官网回包,不按本机时间重算状态或剩余天数。前端不把 null 当成 0。
AgentOrchestrator 在回合开始时记录账户指纹,TrialTurnMeter 收尾及 AccountService.reportTrialTurn 发送前校验归属;发送与 connect/disconnect 使用同一锁,队列中的旧账户回合不得用新账户凭据上报。切账户时保守少计,不在本机扣次。查询复用 MachineAccountGuard,server 普通租户不可读机器余额;回合上报仅 security.local-mode=true 启用,server 未接 per-user 试用契约前不借机器管理员账户计次。官网 app/api/account/trial/{route.ts,turns/route.ts} 已实现 GET 与 POST;本仓旧说明中的“待实现”不是部署状态证据。验证为本地桩与单测,不代表生产账户端到端计费已验证。
关键文件地图
解锁门(PR-A)
backend/src/main/java/com/checkba/service/LicenseService.java— 授权状态机与落盘。两条解锁路 (试用码离线验签 / 账户 Key 在线校验)、~/.aiworkdeck/license.json(0600)、30 天离线宽限、 启动期机会性复验(reverifyOnStartup,后台线程,失败静默)。 2026-08-18 起官方版关掉试用码那条路(security.license.trial-code.enabled), 只剩账户凭据;存量票据有过渡期。见下方「必须账户登录」一节。backend/src/main/java/com/checkba/service/TrialCodeVerifier.java— 试用码纯函数验签(无 Spring 依赖)。backend/src/main/resources/license/trial-public-key.pem— 内置 Ed25519 公钥 (与插件 registry 签名是两套独立密钥对,别混用)。backend/src/main/java/com/checkba/controller/LicenseController.java—/api/license/{status,activate,deactivate}, 匿名端点(解锁前没有身份可言)。activate 里粘awdk_会顺带AccountService.connect()一步到位; 连接失败不回滚解锁但必须可见:响应附accountConnected/accountNotice,unlock 页弹提示(2026-08)。status除 LicenseService 的{unlocked,mode,plan}外另附展示口径accountConnected+edition(paid|trial|none,resolveEdition:账户已连或 mode=account → paid)——授权票据与账户连接是两条独立状态,先试用码解锁、后连账户的用户 mode 永远停在 trial,界面判「试用版/正式版」一律读 edition 不读 mode(userprofile 授权行、顶栏 chip 已改)。 组合是只读的,绝不回写 license.json(改写会抹掉试用码票据,断开账户即掉回未解锁); 非 local-mode 不查 AccountService(机器级状态 + 本端点匿名,照查等于泄露给匿名请求),edition 恒 paid。- 前端:
frontend/src/pages/launch/launch.vue(启动分流页)、pages/unlock/unlock.vue(解锁页, 左栏品牌展示复用components/BrandShowcase.vue)、pages/identity/identity.vue(本机工作区选择页)。 2026-09-29 起登录卡抽成components/account/AccountLoginDialog.vue(variant="dialog"是就地登录弹层,variant="page"是 unlock.vue 这个薄壳页的卡片);组件挂在页面树之外时 uni 的 button/input/image 不存在, 所以一律 div/span/img +<component :is="'input'">,输入框:value+@input(v-model 在<component :is>上 会编译成组件 v-model)。组件本身不跳页,成功 emit('success'),薄壳页自己回 launch。
本机免登身份(PR-A + #250)
backend/src/main/java/com/checkba/service/LocalIdentityService.java— local-mode 下「本机用户」的解析: 已持久化的选择 →username=local→ 按数据量判定(唯一有数据的直接选中;多个有数据则待选定;全空库复用空 admin)。 选择结果落SystemSetting的local.identity.selectedUserId。backend/src/main/java/com/checkba/controller/LocalIdentityController.java—/api/local-identity/{status,candidates,select},同为匿名端点。backend/src/main/java/com/checkba/config/LocalModeAccessFilter.java— 免登模式的每请求准入闸(回环校验 + 反代痕迹拒绝 + 跨站 Origin 硬拦截)。backend/src/main/java/com/checkba/config/LocalModeLoopbackGuard.java— 启动强不变式:local-mode 必须绑回环地址,否则拒绝启动。AuthController.getUserIdFromSession()在 local-mode 下把任何请求解析为本机用户(90 余处调用方一行未改)。LocalIdentityService从不给真实账号改名,但展示名会随官网刷新(spec 2026-09-10 §5):commit()那处改名只收窄到username=admin(系统默认账号,「管理员」不是用户自己起的名字); 真实账号的displayName由AccountIdentitySync按官网这个唯一权威源刷新, 不是「一个字都不动」而是「不被本机改名,只随权威源刷新」。两者不冲突:一个是本机替用户做主,一个是随权威源同步。- 「本机用户」是库里恒存的中文哨兵,界面语言只在读出来时替换(
LocalIdentityService.displayNameOf, v0.30.0 + dev-board#351)。库里存的值一个字都不动——改写入侧会让同一个人在中英文界面下留下两种身份。 出口全集(新增读出口要跟着补,别再漏):/api/auth/me、/api/local-identity/{status,candidates}、 项目成员与 owner(ProjectMemberController)、项目列表managerName(ProjectService)、 变量库创建者(ProjectVariableController.VariableView)、版本时间线署名(ProjectRepoService.toEntry)。 后两处是快照——名字被抄进了 project_variables / Git 提交对象,读时本地化是唯一够得着的修法。 不许就地改实体的creatorName:实体在 OSIV 会话里受管,一旦被脏检查刷回库,库里就成了 「Local user」,中文界面反过来看到英文,且不可逆——所以那条路走的是只读视图VariableView。
账户连接(PR-B)
backend/src/main/java/com/checkba/service/account/AccountService.java— 与官网账户的唯一连接方式是awdk_Key。~/.aiworkdeck/account.json(0600,字段key/username/accountId/displayName/connectedAt/lastSyncAt);connect/disconnect/status/fetchProfile/fetchEntitlements/fetchLedger/fetchAiUsage/fetchAiKey/currentKeyOrNull, 以及个人档案那一组profileIdentity/updateDisplayName/uploadAvatar/deleteAvatar(见下方「身份展示」一节)。service/account/AccountTransport.java+HttpAccountTransport.java— 出站 HTTP 缝(单测打桩不依赖网络);固定 HTTP/1.1。sendMultipart(method,url,bearer,Multipart)是头像上传那条路,默认实现直接抛—— 给个默认返回值会让「桩根本没接这条路」在测试里表现成一次成功的上传。service/account/AccountEndpoint.java— 授权服务器地址的协议校验(https,回环 http 例外),LicenseService与AccountService共用。service/account/AccountException.java—Kind:NETWORK / UNAUTHORIZED / CONFLICT / NOT_CONNECTED / MALFORMED。- 团队端点(精确
/api/account/team或其子路径)的 HTTP 403 +forbidden是角色权限拒绝,归 REJECTED,保留有效会话;401、invalid_key、key_revoked 与其他账户端点的 forbidden 仍走原凭据失败分类。护栏AccountTeamPermissionTest(dev-board#1252/#1296),不能全局放宽 403。 backend/src/main/java/com/checkba/controller/AccountController.java—/api/account/{status,connect,disconnect,usage}以及个人档案/api/account/profile(GET/PUT)与/api/account/avatar(POST multipart/DELETE)。service/account/AccountSwitchCleanup.java— 换账户后作废动作的唯一出口(afterConnect/afterDisconnect): 权益缓存 + 平台 AI 密钥缓存 + 余额判定 + 用量基线四样一起清;disconnect 在团队服务器上还要demotePlatformProvider(),单机版不降级(2026-09-29,见地雷 8)。 连接账户有三个入口(设置页粘 KeyAccountController.connect、登录弹层AccountController.login、 解锁页粘awdk_的LicenseController.activate),动作抄多份必然漏(见地雷 22)。新增连接路径时接这里。 登录后置(2026-09-29,dev-board#1046)又加两件:afterConnect()返回「这次是不是换了一个账户」 (SystemSetting键account.lastAccountId,见核心契约「换账户提示」),afterDisconnect()调AccountIdentitySync.resetToLocal()把本机用户行回退成哨兵名「本机用户」、清头像。service/account/AccountRequired.java— 「需要账户」4011 信封的唯一定义(CODE/REASON_*/exception(reason, msg)/envelope(...)),见核心契约「4011 account_required」。currentKeyOrNull()的消费方现在有三处,不再是「只有广场付费项下载」(dev-board#439):广场付费项下载(Authorization: Bearer awdk_直发官网)、MobileRelayClientService的手机中转桥接、com.checkba.version.OfficialCloudService的官方团队案件库桥接。后两者形状完全相同——拿 Key POST{base}/api/auth/awdk-login换一枚 awdt_ 长期设备令牌存在本机,用accountFingerprintOrNull()判「还是不是同一个账户」,换了人就重桥。新增第四条这类通道时照抄这个形状,别自己发明一套令牌缓存。AccountService.accountFingerprintOrNull()— 账户指纹(Key 的 SHA-256 前 12 位)的唯一定义。 机器级缓存都是账户级内容,换账号必须作废;指纹单向、可比对可进日志,不受「别把 Key 拿出去传」的限制。- 前端:
frontend/src/pages/admin/admin.vue的「账户与用量」分区(连接/断开、余额、AI 额度、最近用量、 本机工作区切换、文件缓存区存储位置);顶栏 chip 在project-overview.vue(已连接账户优先于试用版标识)。
Entitlement 框架(PR-B)
backend/src/main/java/com/checkba/service/entitlement/EntitlementService.java—isEnabled(feature)是唯一出口。 来源并集 = 本地票据(LicenseService 解锁 →app.unlocked)∪ 账户同步(缓存~/.aiworkdeck/entitlements.json)。service/entitlement/FeatureCatalog.java— 中央功能目录,桌面端唯一的 feature 名字来源。backend/src/main/java/com/checkba/controller/EntitlementController.java—GET /api/entitlements(?refresh=true显式同步刷新),要求登录。- 前端:
frontend/src/composables/useEntitlement.js(模块级单例 + 在途去重)、components/UnlockHint.vue(统一解锁引导)。
本地 SKU 的额度执行(PR-C,实现细节见 utility-tools.md)
backend/src/main/java/com/checkba/service/ClipboardService.java— 剪贴板查询侧过滤(FREE_MAX_ITEMS=20/FREE_RETENTION_DAYS=3)。backend/src/main/java/com/checkba/service/quota/StageQuotaService.java— 缓存区移入时拦截(FREE_MAX_FILES=20/FREE_MAX_BYTES=500MB)。backend/src/main/java/com/checkba/exception/StageQuotaExceededException.java+GlobalExceptionHandler(code=4003 + feature + usage)。backend/src/main/java/com/checkba/service/storage/StorageLocationService.java+controller/StorageLocationController.java— 自选存储位置(需stage.unlimited;GET 与 reset 不设权益闸)。
平台 AI 通道与计费(PR-B,编排侧见 ai-chat.md)
backend/src/main/java/com/checkba/service/ai/PlatformAiChannel.java— 取 key 的唯一路由出口。 机器级路径(缓存~/.aiworkdeck/platform-ai-key.json,0600)与 per-user 路径在这里分叉,keyFingerprint()供模型实例缓存失效。缓存文件带owner(签发它的账户指纹), 归属对不上一律丢弃重取(见地雷 22)。usesMachineKey(userId)是余额闸的适用判据。service/ai/PlatformCreditsGate.java— 余额闸:确认账户与正余额后才能启动付费调用, 由ChatModelFactory.platformApiKey()在取 key 之前调用(那是平台通道每条消息的必经点)。 三条判据见地雷 23。service/ai/PlatformUsageAccountant.java— 平台通道真实扣费对账(GET https://openrouter.ai/api/v1/key累计消费差分), 基线按密钥指纹分桶、worker 按指纹分片;兼做吊销探测(401/403 即作废本地密钥)。service/ai/ChatModelFactory.java—Provider.AWD_CLOUD路由;demotePlatformProvider()在断开账户 / 切站时把供应商降级回落(仅团队服务器;单机版不调,见地雷 8)。model/entity/TokenUsage.java的costSource:platform(真实扣费)/estimate(BYOK 单价表估算)。- 前端选平台通道有两个入口,前置条件相同(已连接账户 + 已分配额度)但闸门形态不同,别照抄:
pages/admin/admin.vue的aiProviderOptions——缺条件时展示但unavailable,hint指出下一步 (「需先连接账户」/「需先在官网分配额度」);pages/wizard/wizard.vue的providerOptions——AWD_CLOUD恒可选,选中就地展开连接块把条件补齐, 闸门挪到handleSubmit(见下方地雷 15:向导里的每一条「下一步」都必须能在向导里做完)。 向导刻意不预选任何供应商,见下方地雷 14。 2026-08-21 起向导也收敛成官方版形态(dev-board#98):providerOptions/ OLLAMA 探测 / OpenRouter Key 输入整体删除,步骤 1 改成「连接账户」直述(沿用原 AWD_CLOUD 选中后展开的连接块- 跨境同意框),
buildPayload固定ai.activeProvider='AWD_CLOUD',只带crossBorderConsent, 不带external/ollama*字段(toSettingsUpdates对 null 一律跳过,存量 key 不被清空)。handleSubmit三道闸原样保留:未连账户 / Credits 为 0 / 未勾同意都拦提交。probeOllama的 api.js 封装与后端/api/ai/ollama/probe端点保留,前端已无调用方。
- 跨境同意框),
- 供应商自 2026-08 收敛为三档:
AWD_CLOUD(平台通道)/OPENROUTER(自备 Key)/OLLAMA(本地,离线实验档,只支持 ASK)。2026-08-21 起产品只有官方版(dev-board#98):桌面端设置页 前端不露 BYOK——供应商单选、OpenRouter Key、Ollama 字段、平台服务的 21 个 BYOK 凭证表单全部从AdminPane.vue删掉,form不再回传external/ollama*(toSettingsUpdates跳过 null, 存量 key 不被清空);后端三档枚举、设置键、ExternalProviderBackfill等分支一律保留。 老用户库里ai.activeProvider仍是 OLLAMA/OPENROUTER 时,AI 面板顶部给「当前仍在使用旧的 供应商设置」+「切换到官方通道」一键切回(走既有保存路径,跨境同意闸照旧把关)。本地部署版 另开发,不在主线。下面提到「admin 页的 aiProviderOptions」之处都是这次之前的形态。GEMINI已下线(Google Key 三个字段与external.google.*键一并删除, Gemini 系列模型经 OpenRouter 的google/*仍可用)。两个入口的取值合法性由AdminConfigController.toSettingsUpdates统一校验(非三档枚举直接 400), 存量 DB 里的ai.activeProvider=GEMINI由ChatModelFactory的启动期迁移改写成OLLAMA。 - AI 相关设置的唯一写入口仍是这两处,写入的键:
ai.activeProvider、ai.defaultModel、ai.auxModel、ai.subagentModel(留空 = 继承ai.auxModel)、ai.networkRegion(auto/domestic/international)、ai.ollama.baseUrl/ai.ollama.modelName。三个模型键空串是合法值(= 跟随内置默认), 非空必须在AllowedModels白名单内,否则报 400 而不是让工厂静默回落。两个入口保存后都调chatModelFactory.clearCache()。 toSettingsUpdates跳过 null 字段(不再把同组其余字段写成空串):SystemSettingService只在 行不存在时回退默认值,空串会被当成真实配置,历史上会把 env 提供的 baseUrl / 走wizard/reset重跑向导的正确 baseUrl 清掉(受害者 QichachaService / TushareService / TtsService)。 代价是「清空某字段」需要显式传空串——admin 页整表回传不受影响。
广场付费项(PR-D,链路见 plugin-marketplace.md)
backend/src/main/java/com/checkba/service/market/MarketPurchaseGate.java— Skill 与插件两条安装链路共用的付费判定单一出口。service/market/RegistryReply.java—httpGet(url, bearer)缝的返回值(状态码交调用方判,402 不在缝里抛)。- 前端
frontend/src/utils/marketPricing.js— 价格展示与状态判定的唯一出口。
server 模式加固(插件云后端,2026-08-06)
backend/src/main/java/com/checkba/service/AuthAbuseGuard.java— 注册闸(security.registration-mode: open|closed,默认 open)+ 登录失败锁定(IP+用户名 5 次失败锁 10 分钟)+ 注册按 IP 限频(10/小时)+ 成员查询限频(dev-board#444:按发起的项目管理员,10 分钟 30 次;GET /api/projects/{id}/members/lookup与POST .../members共用这一个计数——两者是同一个「这个手机号注册过没有」的探测面,分开计等于把额度翻倍,交替调两个端点即可绕开)。进程内内存计数,多实例部署必须前置 nginx limit_req;local-mode 全部旁路。新增维度记得同步purgeIfOversized(否则伪造维度能把内存撑爆)。backend/src/main/java/com/checkba/service/account/AwdkLoginService.java— 官网账户 → server 会话桥。核心一段:awdk_ Key 调官网/api/account/me实时校验 →account_binding映射(键是官网稳定accountId,官网侧已实施并进了权威契约与 contract-check)→ 首登UserService.registerExternal建无密码用户(awd_前缀)→DeviceTokenService.issue签发 awdt_ → 顺手为该用户取一把 per-user 平台 AI key(PlatformAiKeyService.tryProvision,失败绝不拖垮桥接)。 Key 有两种来源,桥接之后完全相同:账户登录(loginWithPhone/loginWithPassword,先调官网/api/auth/exchange-key换出 Key,用户看不见它;配套sendLoginCode)与手工粘贴(login(key),私有部署与团队服务器)。两者共用开关security.awdk-login-enabled(默认 false)——同一条桥的两个入口,刻意不拆成两个开关,否则会出现「登录能用但桥是关的」这种自相矛盾的配置。 端点全在AuthController,全部匿名:POST /api/auth/awdk-login、POST /api/auth/account-login、POST /api/auth/account-login/send-code。/awdk-login的回包是跨端契约(Office 插件云后端与桌面端的官方案件库直连都吃它):{token, userId, username, displayName, tokenId},displayName/tokenId是 dev-board#439 加的(BridgeSession同步加了两个分量)——桌面端存下tokenId才撤得掉远端那枚长期设备令牌,否则「退出这个案件库」只做成本地断开、凭据留在服务器上继续有效。tokenId缺失时整个键不下发,回落成 0 会让调用方存下一个不存在的令牌行 id。 与官网的出站在AccountLoginExchange(桌面AccountService与云端AwdkLoginService共用一份 error code 表——invalid_code/invalid_credentials/sms_not_supported_on_site/phone_binding_required等是与官网仓约定的字面量,写两份必漂)。 换 Key 请求随凭据带可选deviceName(2026-08-19,官网 PR#77/桌面 PR#429):桌面端上报「主机名 (Mac/Windows)」(AccountService.deviceName(),截 64 字符),云端桥固定「Office 插件 / Office Add-in(按 baseUrl 分站)」;官网存进api_keys.label,账户页「已连接的设备」按它显示。字段可选,不带时行为不变。 云侧不能复用/api/account/login:那条开头就requireUser(sessionId),local-mode 会自动解析成本机用户所以桌面端没事,local-mode=false下「登录前得先有会话」是死循环。backend/src/main/java/com/checkba/service/account/MachineAccountGuard.java— server 模式下AccountController全部端点与GET /api/entitlements仅 admin 可用(账户连接/权益缓存是机器级状态,普通租户 disconnect 一下全服平台 AI 通道就断);local-mode 恒放行一字不动。model/entity/AccountBinding.java+repository/AccountBindingRepository.java— 官网账户 → server 用户映射表;awdk_ 明文不落库(每次桥接重验官网)。service/UserSessionService.java+model/entity/UserSession.java— 浏览器登录会话 DB 落库(2026-08-07, 替代 AuthController 进程内 SESSION_STORE):哈希落库、滑动过期、lastUsedAt 写回节流(1 分钟)、 每日定时清理;重启不掉线。awdt_ 设备令牌与 local-mode 免登不走这条路,行为一字未变。 滑动过期天数自 2026-08-19 起可配(security.session-idle-days):代码默认 365 天(「常驻」语义—— 短信/邮箱验证码登录每条有真实成本,7 天一断线逼用户反复走验证码), 官方云后端在application-cloud.yml显式配 7,已上线的「7 天滑动过期」契约逐字不变; 改默认值或动 cloud 配置前先看UserSessionServiceTest.cloudProfilePinsSevenDays。
per-user 平台 AI key(2026-08-07,多租户计费隔离)
- 设计文档
docs/superpowers/specs/2026-08-07-per-user-platform-ai-key.md(含三方案的安全边界比较与已确认决策)。 service/ai/PlatformAiKeyService.java— per-user 密钥的唯一出口:resolve/provision/tryProvision/refresh/evict/markVerified/status。isBound()(该用户有account_binding)与multiTenant()(本实例存在任一绑定)是两条路由判据。service/ai/PlatformAiKeyCipher.java— AES-256-GCM 落库加密,密文形态v1:iv:tag:cipher与官网仓lib/openrouter-keys.ts逐字对齐;构造器里带启动强不变式(见地雷 17)。model/entity/PlatformAiKey.java+repository/PlatformAiKeyRepository.java— 每用户至多一行, 存密文 + 指纹 + limitUsd + fetchedAt/lastVerifiedAt。刻意不挂在account_binding上: 那张表是纯身份映射且每次桥接都要读,塞密文会改变它的安全等级。service/ai/PlatformAiUserScope.java— 「这次调用花谁的额度」的线程作用域。 设置点共 10 处:AiAgentController(对话与 PPT 生成两条异步入口共用这一处 run)、AgentOrchestrator四处(handleUserMessage 入口 run、标题生成 runAsync wrap、 onComplete / onError 两个回调 run、LLM 重试 scheduler wrap)、ToolRegistry.execute(按ctx.userId()call,2026-08 新增)、MemoryPipelineService、SubAgentService.dispatch(优先按parentCtx.userId()显式重建,缺 userId 才回落current())、MatterClassifierService、AutoTaggingService。跨线程提交必须wrap。 (AiChatService 那一处随 v1 同步对话通道删除消失。) ToolRegistry 这一处是 2026-08 补的 P0:工具方法内部发起的 LLM 调用(sub-agent 派发、 deep_search 的查询扩展)此前跑在没有作用域的线程上,平台通道取不到 per-user 密钥。controller/PlatformAiKeyController.java—/api/platform-ai/key/{status,refresh}, 会话级不是机器级(不走MachineAccountGuard——那道闸管的是整台服务器的账户连接)。- 插件侧:
office-addin/taskpane/components/SettingsView.vue的「AI 额度」卡片taskpane/lib/api.js的fetchPlatformAiStatus/refreshPlatformAiKey。
设备令牌的本机签发(2026-08-07,Office 插件接入)
AuthController.issueLocalDeviceToken—POST /api/auth/device-token/issue-local(body{name}可空): 仅 local-mode,身份走会话解析(免登下恒为本机用户),签发deviceTokenService.issue,明文只返回一次。 为什么另开端点:/api/auth/device-token是密码入口(与 /login 同锁定同二次验证闸),本机免登用户没有密码。 安全边界不新开口子:LocalModeLoopbackGuard + LocalModeAccessFilter 保证只有本机进程可达; 非 local-mode 回业务错误(团队服务器仍只有账号密码一条路)。护栏AuthControllerLocalDeviceTokenTest。- 前端 UI:
userprofile.vue「插件访问令牌」分组(仅桌面显示):生成(备注名+明文一次性展示+复制)/列表/撤销; api.jsissueLocalDeviceToken/listDeviceTokens/revokeDeviceToken。
登录二次验证的判定出口(2026-08-07)
backend/src/main/java/com/checkba/service/auth/SecondFactorService.java—required(user)是唯一判定出口, 返回NONE / TOTP / MAIL / SMS。优先级 TOTP > 邮箱 > 短信:TOTP 零成本、无国界、不受运营商报备与 SIM 交换影响;邮箱排在短信之前是成本决策(短信按条计费,邮件几乎免费),短信降为未绑邮箱时的兜底。 两条密码入口(/login、/device-token)都只接AuthController.secondFactorChallenge()这一个私有方法—— 新增第三条密码入口时必须接它,漏一条等于没设闸。- 同文件管认证器绑定:
startSetup(生成密钥,尚未启用)→activate(验一次码才启用)→disable(必须带当前码,否则被借用的会话能直接摘掉二次验证)→resetByAdmin(认证器丢失的唯一出路)。 service/totp/TotpService.java— RFC 6238 纯算法(HMAC-SHA1,不引依赖), 护栏是 RFC 6238 Appendix B 官方测试向量(TotpServiceTest)。测试要造合法码用src/test/.../totp/TotpTestCodes(放在 totp 包的 test 源码里,刻意不把「凭密钥造码」提升为生产 API)。- TOTP 重放拦截:
User.totpLastUsedStep记录已消费的时间片,同一枚码在其 30 秒窗口内只能用一次。 - 端点:
/api/auth/totp/{setup,activate,disable}(需登录)、/api/auth/totp/reset/{userId}(仅 admin)。 - 前端:
userprofile.vue设置页「账号安全」的认证器分区(二维码用qrcode懒加载渲染, otpauth URI 含密钥,只在前端本地转二维码,不走任何图片服务);login.vue按 4005 的data.method区分 totp/mail/sms 三种步骤(totp 不发码、不显示重发倒计时;mail 与 sms 的重发要打 各自的端点,打错会被判成「未绑定手机号」)。
登录短信验证(2026-08-06,sms-auth-integration)
backend/src/main/java/com/checkba/service/sms/SmsService.java— 阿里云 dysmsapi 发送,刻意不引 SDK (JDK HttpClient 直签 HMAC-SHA1,保补丁通道资格;签名算法有真机对拍向量护栏SmsServiceTest)。 阿里云原始错误 Message 只进日志不进用户文案。service/auth/VerificationCodeStore.java(原service/sms/SmsCodeStore.java,2026-08-08 更名)—— 验证码生命周期,短信与邮件共用一套:6 位数字、5 分钟 TTL、一次性核销、单码 5 次验错作废、 60 秒重发冷却、单 target 日上限 10 条;内存只存 SHA-256。发送失败调invalidate回滚冷却(日配额不回滚)。target短信是规范化手机号、邮件是规范化邮箱,两者不会撞键(手机号里没有@)。service/sms/SmsAuthService.java— 流程编排:scene 隔离(login 码≠bind 码)、号码规范化与校验、 绑定唯一性(发码与确认两处都查)。active()= server 模式 && 任一通道可用;local-mode 恒旁路。- 通道按号码归属地分流(
SmsGateway接口,2026-08-07):SmsService收大陆号(阿里云),TwilioSmsGateway收境外号(Twilio Messages API,同样不引 SDK)。两条独立开关,只配国内的部署 遇到境外号会明确回「该号码所在地区暂不支持短信验证」,而不是发出去再失败。 存储形态:大陆号一律 11 位裸号(+86前缀会被剥掉,否则同一个号能因写法不同绕过唯一性绑到两个账号), 境外号存 E.164。Twilio 侧的各国合规(Sender ID / 10DLC / DLT)在控制台的 Messaging Service 里配, 代码只认messaging-service-sid——加国家不需要改代码发版。 User.phone(unique 列)+UserRepository.findByPhone。- AuthController:
/login与/device-token同一道闸——已绑手机号且启用时缺smsCode回 code 4005 +{smsRequired, phoneMasked}(与 4001/4003 同族,前端 api.js 据此切验证码步骤); 存量未绑定用户不拦。POST /api/auth/sms/send-code(scene=login 须带正确用户名密码且与 /login 共用 失败锁定,否则就是免锁定的密码试探口;scene=bind 须已登录)、POST /api/auth/sms/bind。 IP 维度限频在AuthAbuseGuard.checkCodeSendRate(20 条/小时,短信与邮件共用这一把闸—— 各开一把等于换个通道就能绕过限频)。 /api/auth/me多回smsAuthEnabled+phoneMasked+mailAuthEnabled+emailMasked(空串=未绑定,Map 不收 null;该 Map 已 12 项,超过Map.of的 10 对上限,用的是Map.ofEntries); 绑定 UI 在userprofile.vue设置 tab「账号安全」,登录验证码步骤在login.vue。
登录邮箱验证(2026-08-08,PR#320)
backend/src/main/java/com/checkba/service/mail/MailAuthService.java— 流程编排,与SmsAuthService同构。 三个场景mail-bind / mail-login / mail-signin互不通用:绑定码是已登录态下发的, 若能用于免密登录,等于把低权限操作兑换成完整登录。service/mail/(MailGateway/SmtpMailGateway/DomesticMailGateway/GlobalMailGateway/MailRouter)——MailRouter按收件域名选路,国内主流邮箱走阿里云 DirectMail(dm.aiworkdeck.com), 其余走 Resend(send.aiworkdeck.com,兜底通道)。刻意不用 Spring Boot 的spring.mail.*—— 那套只装配得出一个JavaMailSender,两条必须并存。@Order钉死顺序,兜底通道排错会把 QQ/163 全吃掉。User.verifiedEmail(新增的 unique 列)+UserRepository.findByVerifiedEmail。 不要给资料字段User.email加唯一约束:它是历史自由填写的,有重复与空串,ddl-auto: update加约束会在脏数据上失败。verifiedEmail只由「收到码并验过」写入。- 端点:
POST /api/auth/mail/send-code(scene=login/bind,与短信同构)、POST /api/auth/mail/bind、POST /api/auth/mail-login/send-code+/verify(免密登录两步)。 - 免密登录的三条红线:① 独立开关
mail.passwordless-login-enabled且默认关——它是一条新的匿名 登录入口,邮箱被盗即账号被盗;② 未注册邮箱不发信但照常返回,回包对「已注册/未注册」完全一致, 否则这个端点就是账号枚举器;③ 验码失败必须计入登录锁定(锁定键=规范化后的邮箱)——单枚码的 尝试上限只管那一枚,换一枚重来不受限,不接锁定就是个 6 位码爆破口。 - 配置
mail.*(application.yml):两条通道各自enabled/host/port/username/password/from, 默认全关。Resend 的 SMTP 用户名是字面量resend、密码填 API key;from必须含@(回落到 username 会拼出非法发件人,只在发信那一刻才炸,所以enabled()里就判掉)。 阿里云那条的密码是控制台「发信地址 → 设置SMTP密码」设的,不是 AccessKey。 - 配置
sms.*(application.yml):SMS_AUTH_ENABLED默认 false;AK/SK 走SMS_ACCESS_KEY_ID/SECRET环境变量(RAM 子用户仅授 AliyunDysmsFullAccess),签名/模板默认京微资易科技/SMS_483655011(旧签名京微资易已在阿里云控制台删除,2026-08-06 重建为新签名)。 签名的运营商报备状态是外部前置条件:2026-08-07 实测联通已通(真机送达),移动/电信报备中, 未通的运营商发送会 PORT_NOT_REGISTERED;报备状态用GetSmsSignAPI 可查。
手机号强制登录与补绑期(2026-08-18)
- 设计
docs/superpowers/specs/2026-08-17-phone-login-design.md§5。官网侧同一口径的参照实现是aiworkdeck_website的lib/phone-policy.ts(gateForUser())+app/api/auth/login/route.ts。 config/PhoneLoginGuard.java一个类兼两职:启动期断言(开了强制但短信网关是暗的 → 拒绝启动, 不静默降级成谁都进不来)+运行期三态闸gateFor(user)→OK/MUST_BIND/BLOCKED。required = phone-login-required && !local-mode——local-mode 免登没有登录环节,闸恒不生效。- 接线在
AuthController的三条会签发凭据的路径:/login、/device-token、/mail-login/verify。 只护住/login是假闸——换个端点就绕过去了,而/device-token发的还是长期凭据。/awdk-login(awdk_ 桥)刻意不管,它不走密码也不走手机号;/sms-login/verify天然已绑号。 - 拒登必须落在
issue()之前:先签发再拒等于已经把一个能用的凭据交出去了。用例AuthControllerPhoneGateTest用verify(sessions, never()).issue(...)钉住这条。 - 拒登码是 4006,绝不能用 4010:4010 是前端
api.js:288认定的「会话失效」专用码, 回它会让客户端清掉本地会话并弹回登录页,文案也一起被冲掉,被锁的人就不知道该去发邮件。 - 放行时回包带
data.mustBindPhone(/login、/device-token、/mail-login/verify三处), 客户端据此弹不可跳过的强制补绑,走现成的POST /api/auth/sms/bind。 - 期限默认
2026-09-30,与官网DEFAULT_BINDING_DEADLINE同值,改一边必须改另一边, 否则会出现「官网说还能用到 X 日、后端 X-30 日就把人拒了」。当天仍算期限内;格式配错回落默认值。 - 已知未闭合的边:
/register没接这条闸。security.registration-mode代码默认open(cloud profile 显式closed),所以只有「开了强制手机号 + 又开着注册」的部署才踩得到: 期限后新注册仍会拿到一个能用的会话,直到下次登录才被拒。要不要一并关掉是待定的策略问题。
站点(双主站,2026-08-08)
- 设计文档
docs/superpowers/specs/2026-08-08-dual-site-architecture.md(含实施记录一节,与设计有出入以那节为准)。 - 两个站分的是商业与合规:币种、支付通道、发票、适用法、ICP 备案、默认语言、
telemetry 落点、registry 落点。国内站
www.aiworkdeck.com(北京 ECS、微信支付、人民币、中国法), 国际站www.workdeck.ai(新加坡 ECS、Stripe、美元、香港主体)。两站账户不互通是接受的代价。 - 绝不按站点过滤模型清单:桌面端所有 OpenRouter 请求从用户本机直连 openrouter.ai
(
ai.model.open-router.base-url,平台通道 AWD_CLOUD 刻意只读 yml 的 baseUrl), 能不能用某个模型由出口 IP 决定、由 OpenRouter 运行时返 403,我们没有任何开关能用注册地解锁境外模型。 service/site/SiteProfileService.java— 站点的唯一解析出口:currentSite/profile/baseUrl/ displayName/availableSites/multiSite/otherSites/isPinned。pinnedTo(baseUrl)是单测与 「站点无关」场景的构造入口。service/site/SiteEnvironmentPostProcessor.java+resources/META-INF/spring/ org.springframework.boot.env.EnvironmentPostProcessor.imports— 启动期按site.json改写四个属性(ai.account.base-url、ai.plugins.registry-url、ai.skills.registry-url、telemetry.ingest-url)。在属性层解析,是为了让service/ai/下的三个消费方一行都不用改。 属性源插在systemEnvironment之后:压过 application.yml,输给环境变量(本地联调靠这条)。service/site/SiteStateFile.java—~/.aiworkdeck/site.json,刻意不依赖 Spring 与 Jackson (要在容器起来之前读一次)。站点是机器级状态,与 license/account 同目录同规格; 刻意不进数据库——local.identity.selectedUserId进数据库是因为它是指向库内 user 表的外键必须同生共死, 站点描述的是「这台机器面向哪个商业实体」,还原旧库不该把站点还原掉。service/site/SiteSwitchService.java— 切站编排,是persistSelection的唯一合法调用方。controller/SiteController.java—GET /api/site、POST /api/site/select,匿名端点 (选站发生在解锁之前),靠LocalModeAccessFilter兜着,做法同POST /api/license/deactivate。ai.account.sites.*(application.yml)— 站点表;intl.enabled自 2026-09-23 起为 true (国际站上线,dev-board#846,PR#955;SiteProfileServiceTest钉住),可选站点 < 2 时前端 整个不渲染站点 UI(这一条判据不变,只是现在两站都开着,常态就会渲染)。- 前端:
frontend/src/utils/siteLinks.js(官网链接的唯一出口,替代 7 处硬编码)、pages/unlock/unlock.vue(解锁页重做,2026-09-23,dev-board#846-848,设计docs/superpowers/specs/2026-09-23-desktop-login-and-dialog-redesign-design.md)、pages/admin/admin.vue「账户与用量」的站点子区。解锁页新结构:站点分段控件 (unlock-site-seg,multiSite=false整个不渲染,pinned时渲染但不可点,标识符按站点切换 ——cn 用手机号、intl 用邮箱,两个输入框各自保留互不覆盖);登录与注册合一为一个入口 (官网验证码端点「不存在即注册」,回包带isNewUser,页面上没有单独的注册页/注册按钮);onSiteSegTap切站只有本机确实有东西会被清掉才二次确认(needsSwitchConfirm:已连账户 或用账户 Key 解锁),解锁页常态两样都没有,切站只是换登录目标,不弹确认;首装按界面语言 自动预选站点(非中文 → 国际站,否则大陆站,且仅在不会清掉本机东西时做)只做一次, 落盘标记awd_site_preselected——与切站时界面语言随动(用户没亲手选过语言时才生效)用的utils/appLanguage.js的awd_app_language_manual(用户亲手选过界面语言,程序自动切换 不打这个标记,{ auto: true })是两个独立的本机存储标记,分别管「站点只自动预选一次」与 「语言是否被用户亲手定过」。切站语言随动(dev-board#864)两个方向都跟:国际站 → 英文、 大陆站 → 中文,判定是utils/siteLanguage.js的siteLanguageToApply(护栏npm run test:unlock); 解锁页上切语言(随动与底部「中文 · English」)是就地切——setAppLanguage+i18n/index.js的applyI18nLocale+ 页面响应式uiLang,同一帧整页换语言,不再 600ms 后整页 reload(闪屏、切回大陆 不回中文都是旧实现的毛病);本页切过语言时离开走goLaunch()整页重载进启动分流(模块顶层取过的 静态文案要按新语言重建),没切过照旧reLaunch。本页新增读语言的地方一律读this.uiLang/$i18n.locale,别读isEnglish()——它不是响应式的,就地切换后不跟(BrandShowcase 的is-en踩过)。 试用码/手工粘awdk_Key 那条路只在trialCodeEnabled时才有,由卡片底部链接进入(官方发布版关闭,见「必须账户登录」一节),不再是常驻的页签。 品牌文案(十类工作来源、标语等)唯一来源是design/copy/brand-copy.json,与onboarding.unlock.brand.*的 i18n 值逐字对拍(frontend/scripts/check-brand-copy.mjs, 已并入npm run check:locales)——改文案先改 json,别直接改组件里的字符串。 - 国际站人机验证(Cloudflare Turnstile)必须走官网托管页,不能在本页 render(2026-09-23):
Turnstile 按域名放行 sitekey,打包版主窗口是
file://,直接turnstile.render必报 110200 (真实 sitekey 实测),拿不到 token 官网send-code就回captcha_failed。utils/captcha.js的 turnstile 分支改嵌 iframe{siteBaseUrl}/captcha-embed?lang=zh|en&theme=light|dark, 纯逻辑在utils/captchaEmbedCore.js(护栏npm run test:captcha)。契约要点:消息都带source:'awd-captcha';页面 → 父ready/token/error/size/disabled,父 → 页面reset、get-token; 父页过滤两道都要——event.source === iframe.contentWindow(父页 origin 是null,只认这个窗口) 且event.origin === 官网 origin(框被导航走时窗口对象不变,只有 origin 能识破); 父发消息的 targetOrigin 钉官网 origin。取 token 先reset再get-token,8 秒超时回空串, 等待中的空串 token 不收口(reset 回调)。阿里云(大陆站)在file://下本来就好,那条分支没动。 切站重装配前必须teardownCaptcha(),否则旧 iframe 的 message 监听留在 window 上。 - 「未启用」与「装不出来」是两回事,后者绝不盲发码(dev-board#1056,律所网络拦了 o.alicdn.com 的真机事故):
captcha-config拿不到或provider为空 → 未启用,照常不带 token 发码;provider非空而控件装不出来 (脚本被拦/超时、initAliyunCaptcha缺失、托管页挂不起)→setupCaptcha抛utils/captchaFailure.js的captchaLoadError(code='captcha_load_failed'+provider+reason),装上后才暴露的失败(阿里云onError、托管页readyTimeoutMs内没 ready → controllerstate.loadFailed)走控件的loadError()。 登录卡components/account/AccountLoginDialog.vue(解锁页薄壳与就地登录弹层共用)记captchaFailure,点「获取验证码」先等在途装配、再自动重试一次装配,仍失败才报onboarding.unlock.captchaLoadFailed{Aliyun,Turnstile}(点名要放行的地址:o.alicdn.com / 官网域名 + challenges.cloudflare.com)。脚本加载失败要从loading缓存里摘掉,否则重试永远拿同一枚 reject。setupCaptcha在阿里云脚本到齐之后若发现装配已被取代或挂点已随弹层卸载,回 null——这只在两道 throw 之后判, 且那时结果已无人消费,不算「装不出来」。 护栏tests/captcha/captcha-failure.test.mjs。 - 桌面壳里托管页挂
<webview>,不挂 iframe(dev-board#863,2026-09-23 真桌面壳实测):主窗口webPreferences.webSecurity=false,嵌在这种 WebContents 里的 Turnstile 挑战帧会被 Chromium 以Terminating renderer for bad IPC message, reason 1杀掉渲染进程,控件卡死([Cloudflare Turnstile] Error: 300030,约 43 秒一轮重试、永远不出 token)——用户看到的是「发送中」转 8 秒后报「请完成人机验证」, 界面上什么都不出现(interaction-only 本来就隐形)。同一个 file:// 父页只把 webSecurity 改成 true, 6 秒出 token。别拿「把主窗口 webSecurity 打开」当修法(那一位牵连跨域 Cookie 等历史行为)。 现形态:主进程ipcMain.handle('checkba:captcha-embed')只回{ preload },preload 暴露为host.captchaEmbed.getConfig();utils/captcha.js有这条能力就建<webview>(preload =desktop/preload/captcha-webview-preload.js,web security 默认开),否则(Web 版、老壳)退回 iframe。 托管页在 webview 里是顶层页,window.parent就是它自己,preload 在同一个 window 上截住source===window && origin===location.origin的协议消息经sendToHost('awd-captcha')交宿主, 宿主wv.send('awd-captcha', {reset|get-token})由 preload 投回页面(托管页的e.source === window.parent在顶层页恰好成立,官网侧一行不用改);宿主端过滤走acceptBridgeMessage(origin 仍卡官网)。 频道名两端都是字面量awd-captcha,test:captcha对拍。排查这条链别挂 CDP:Cloudflare 会探测 DevTools(Runtime.enable 序列化带 getter 的对象,控制台刷%c%d font-size:0就是它),挂着 puppeteer 取不到 token 是测试自己造成的;用主进程executeJavaScript+capturePage驱动。 另:托管页判定需要人点勾选框时会发size且高度 > 0,控制器此时把在等的请求放宽到 60 秒 (interactiveTimeoutMs),否则 8 秒一到回空串、用户点完再按发送又 reset 出新挑战,永远绕不出去。
配置
security.local-mode(application-desktop.yml:36为 true,默认 false = 团队服务器模式)。ai.account.base-url(application.yml:97-98,默认https://www.aiworkdeck.com;强制 https,回环 http 例外供本地联调)。security.license.dir(默认${user.home}/.aiworkdeck)——license/account/entitlements/platform-ai-key/storage-location 五个状态文件都落这里。cloud.collab.base-url(CLOUD_COLLAB_BASE_URL,默认空)——团队案件库地址。留空时按ai.account.base-url所属站点派生官方案件库(大陆站https://case.aiworkdeck.com,国际站不提供);派生与校验的唯一出口是com.checkba.version.OfficialCloudEndpoint,机制见 version-control 领域文档。security.registration-mode(默认 open)与security.awdk-login-enabled(默认 false)——两者都只影响 server 模式;官方托管的插件云后端应配 closed + true。- 官方托管实例已上线(2026-08-07):addin.aiworkdeck.com,北京 ECS 与官网共机;专用 profile
application-cloud.yml(PG + pgvector、RemoteIpValve——不开的话反代后按 IP 的失败锁定退化成全站一把锁); 部署材料与实录见deploy/cloud/README.md。 security.platform-key-secret(AWD_PLATFORM_KEY_SECRET,默认空)——per-user 平台密钥的落库加密密钥。awdk-login-enabled=true时必配,缺失直接拒绝启动(见地雷 17)。sms.enabled(SMS_AUTH_ENABLED,默认 false)——大陆短信通道开关,仅 server 模式生效;官方托管的插件云后端应配 true + 注入 AK/SK 环境变量。sms.intl.enabled(SMS_INTL_ENABLED,默认 false)+TWILIO_ACCOUNT_SID/AUTH_TOKEN/MESSAGING_SERVICE_SID——境外短信通道。 认证器(TOTP)不需要任何配置,server 模式恒可用,是国际用户的推荐路径。
核心契约
「退出登录」(frontend/src/utils/signOut.js,2026-08-18 起;2026-09-29 收成单动作,见本节末)
桌面端此前全应用没有登出入口:个人中心那个按钮写着 v-if="!isDesktop",桌面端不渲染;
能找到的两个近亲各只做一半,且都藏在设置页深处——「系统设置 → 账户与用量 → 断开连接」
只摘账户,「个人中心 → 设置 → 授权 → 解除授权」只清授权票据。现在收成一个动作。
2026-08-27(dev-board#205)「断开连接」按钮已废:AdminPane 账户卡那颗改成
「退出登录」(onSignOut → signOut()),入口共三处:顶栏头像下拉、
账户与用量 → 账户卡、账户与安全 →「登录」组(外加应用菜单 app.logout)。
用户可见文案里只有「退出登录」一个词;「解除授权」保留为账户与安全里的高级动作
(只清本机解锁票据),配了说明文案。别再新增自拼 disconnect/deactivate 的入口。
SKU 解锁的前端刷新契约(dev-board#201,2026-08-27):UnlockHint 购买成功
(含 already_owned)后除 awd:wallet-refresh 外还广播 awd:entitlements-changed
(payload {skuId})。被额度挡内容的面板必须订它重拉列表——hiddenCount/limited
都是后端算的,只刷权益单例不重拉列表,横幅会停在购买前的旧值(剪贴板与暂存区
都踩过)。现有订阅方:ClipboardPanel(refresh)、project-overview(loadStagingUsage)。
登录后置(2026-09-29,dev-board#1046,设计 docs/superpowers/specs/2026-09-29-defer-login-welcome-tab-design.md §5.4)
起「退出登录」只剩一个动作,上面那张「两层分开判」的表作废:
| 本机状态 | 动作 |
|---|---|
连着账户(不论 mode) |
确认 → disconnectAccount() → broadcastAccountChanged({connected:false}) → 停在当前页面 |
| 没连账户 | 弹一句「没有可退出的登录」,什么都不做 |
一律不再调 deactivateLicense():启动不设门了(launch 不读 unlocked),授权票据留着无害;
08-18 那条「mode=trial 绝不 deactivate」的红线随之自然消失。也不再 reLaunch 回启动页——
回去只会原样回到工作台,还把标签页全销毁。「解除授权」仍是账户与安全里的高级动作,行为不变。
界面各处的刷新由 awd:account-changed 驱动(见下节),调用方不要自己在 signOut() 之后补 reload。
就地登录与 4011 account_required(2026-09-29,dev-board#1046)
启动不再要求登录;需要账户的功能在用到时就地登录,登录成功回到原动作,绝不踢回启动页。
前端两个出口:
frontend/src/utils/requireAccount.js的requireAccount({reason, force?, auto?}) → Promise<boolean>: 已连接直接 true(连接状态缓存 30 秒——GET /api/account/status会顺手打一次官网/me,不能每次发送都查); 否则在<body>下单独createApp挂登录弹层components/account/AccountLoginDialog.vue(variant="dialog"), 打开期间持有全局浮层(overlayState.js,让 BrowserView 让开);成功 true 并广播,取消 false。 并发调用共用一个弹层。纯逻辑在requireAccountCore.js(零依赖,护栏npm run test:account)。 浏览器端(团队服务器)恒 true——那里账户在服务端按人桥接,不是「本机连官网账户」。services/api.js的 4011 分支:就地弹层(auto:true——用户取消后 60 秒内同类自动触发不再追着弹), 关掉之后一律 reject,err.accountRequired=true,登录成功时err.loggedIn=true(isAccountLoginRetry(err)为真,message 换成「已登录,请再操作一次」),调用方自行决定要不要重试。 刻意不 resolve:4011 意味着那次请求没执行,resolve 一个「成功」形状出去,不认识它的调用方会弹 「安装成功」这类假成功。后台轮询这类不该打扰人的请求传accountPrompt:false,直接 reject 不弹层。 4010 分支一字未动——4011 不清会话、不跳页。
登录成功 / 退出登录的广播只有一个出口 broadcastAccountChanged(payload)(requireAccount.js):
发 awd:account-changed(payload {connected, previousAccountDiffers?, aiProviderFallback?})、
awd:market-changed(-from-sidebar)(广场付费项按钮形态)、refreshEntitlements(true) 完了再发
awd:entitlements-changed。现有 awd:account-changed 订阅方:requireAccount.js 自己的缓存、
AdminPane(账户分区/钱包/资料)、TeamPanel、MeetingRecordingPanel;C 卡的 rail 账户入口与顶栏
要订同一个事件(LicenseController.status 的 graceKind/daysRemaining 形状不变)。
接入点(缺一项算没做完):AI 发送(ChatInterface.ensureAiAccount,除显式本地 OLLAMA 外均检查)、
广场付费项三处(MarketPane/MarketSidebarPanel/MarketDetailPane 的「需连接账户」→ 登录后按新账户重拉,
已购就直接装)、团队(TeamPanel 空态、CollabDialog/InviteMemberDialog 的「放进官方案件库」)、
手机端同步(设置页「账户与用量」新增的一节,未登录时如实说+登录按钮)、会议转写平台档
(MeetingRecordingPanel:档位下的登录按钮、关掉「不出本机」、点转写前)、设置页「账户与用量」
(主按钮「登录」,粘 Key 折成「高级」)、以及 4011 兜底覆盖的平台网关服务(OCR/企查查/法宝/Tushare/搜索)。
后端形状(service/account/AccountRequired.java,唯一定义):HTTP 200,
{code:4011, kind:"NOT_CONNECTED", reason, message},reason ∈ platform_ai | gateway | market | team | mobile | meeting | dictation(缺省不带这个键,前端用通用说明)。
GlobalExceptionHandler.handleAccountException:AccountException(NOT_CONNECTED)→ 4011,其余 kind 维持{code:1, kind, message, reason?}。AccountController与PlatformAiKeyController的本地 handler 优先、 维持 code=1——设置页/个人资料读账户接口本来就是「没连就降级显示」,不该因此弹登录层。handleGateway:GatewayException(NOT_CONNECTED)在原有gatewayKind/canUseOwnKey之上加code:4011+kind+reason:"gateway";其余网关失败形状一字不改。- 四处统一抛
AccountRequired.exception(reason, msg):广场付费项(MarketPurchaseGate.bearerFor/paymentRequired未连账户分支,reason=market;paymentRequired返回类型因此改成RuntimeException,PluginMarketService解析 bundle 那层 catch 与两个 market 控制器的catch (Exception)都要先放行AccountException,否则会被裹成「清单无法解析」/code=1)、官方案件库(OfficialCloudService.connectOfficial, reason=team)、会议转写平台档(MeetingTranscriptionService.startTranscription,reason=meeting)、 语音听写(VoiceDictationService,没有账户 reason=dictation;账户在但取不到密钥仍是通道错误)。 - 听写端点改成 HTTP 200 + JSON 信封(原来 400/502 + 纯文本):插件
taskpane/lib/api.js的postDictate同步改为判data.code !== 0抛错,否则失败会被当成「转出来是空串」。 - Agent SSE
error事件:账户类失败的载荷从裸文案改成 JSON{message, code, kind, reason?}(AgentOrchestrator.accountErrorPayload,NOT_CONNECTED 的 code=4011、reason 缺省 platform_ai,其余 code=1); 其余 error 路径载荷仍是字符串。插件 chatSession.js 本来就先JSON.parse(data).message,两种都接得住; 桌面useAgentStream.parseAccountErrorPayload见 4011 在气泡里追加一句并就地弹层(不自动重发)。
首启初始化归后端(2026-09-29,dev-board#1046)
原来解锁页登录成功时 completeSetup() 提交向导(activeProvider=AWD_CLOUD + 跨境同意)。登录后置之后那个
时点没有了,改为 DataInitializer.defaultToPlatformChannel():local-mode 启动期,ai.activeProvider
为空且向导标记不是 "true" 时写 AWD_CLOUD + system.wizard.completed=true(一次 setMany)。
只填空不覆盖(用户选过 OLLAMA 一个字都不动);非 local-mode 不动(团队服务器仍走向导)。
不在 ExternalProviderBackfill:那个管的是七家外部服务的档位,与 AI 供应商无关。
跨境同意不在后端默认值里记:默认成官方通道本身不让内容出境,出境要先有账户,而连账户的入口
(登录弹层、设置页粘 Key)都在提交前取得两项同意并记录(frontend/src/utils/accountConsent.js 的
recordAccountConsents():协议版本 POST /api/license/agreement + POST /api/admin/config {ai:{crossBorderConsent:true}},
失败不拦路)。新增连接账户的入口必须接这一步——后端的跨境闸只在「切到官方通道」那一刻把关,
默认值不经过那一刻。前端 getWizardStatus/submitWizard 两个 api 封装随之删除。
换账户提示与断开回退(2026-09-29,dev-board#1046,设计 §5.5)
previousAccountDiffers:true:/api/account/connect、/api/account/login、/api/license/activate(粘awdk_)的回包在「这台电脑此前连过另一个账户」时带这个键,一次性(记录随即改成新账户,同一账户再登录不再带); 没换人时不带这个键,回包形状与改造前一致。判据是官网稳定accountId,落SystemSettingaccount.lastAccountId(与local.identity.selectedUserId同库同生死);不用accountFingerprintOrNull()—— 那是 Key 的摘要,每次验证码登录官网都签发新 Key,同一个人重新登录就会被误判成换了人。断开账户时不清这条记录。 官网没给 accountId 时不猜、不提示、不覆盖。前端(登录弹层、设置页粘 Key)据此弹一次「本机项目属于这台电脑, 不随账户走」的说明(维护者拍板),不阻塞。- 断开后本机行回退:
AccountIdentitySync.resetToLocal()(由AccountSwitchCleanup.afterDisconnect()调)把 本机用户行displayName写回中文哨兵「本机用户」、清头像;纯本地、不发DisplayNameSynced、非 local-mode 短路。 /api/account/login成功后立即identitySync.refreshQuietly()(与/connect对齐),不等下一次 status。
试用码格式与验签
AWD-T- + RFC4648 大写 base32(无 padding;连字符只作分组,解析时连同空白一起剥掉,大小写不敏感)。
解码后恰为 70 字节 = payload(6B) + Ed25519 签名(64B,对 payload 签);
payload = [0x01 版本, 0x01 类型 trial, iat_u32_BE 签发秒级时间戳]。
公钥内置于 backend resources,离线验签,解锁全程不联网。
验签只校验签名与结构,不校验 iat 过期——码本身永久有效。
2026-08-18 起官方发布版不再受理试用码(见下节「必须账户登录」):README 里那枚公开码已撤下,
解锁页的「获取试用码」外链随 trialCodeEnabled=false 一并隐藏(原先指向 README 锚点,
撤码等于弄坏那个按钮,两件事必须同一个 PR 做完)。
验签实现(TrialCodeVerifier)与签发脚本(官网仓 PR-W4)都原样保留,改格式仍要两仓同步。
账户 Key(awdk_)
awdk_ + 43 字符 base64url。官网只存 SHA-256 哈希,明文只在生成时显示一次,每账户最多 3 把,可随时吊销。
桌面端把它落在 account.json,绝不回给前端——AccountService.status() 只暴露 keyMasked
(awdk_**** + 末 4 位)。需要自行向官网发鉴权请求的服务(当前只有广场付费项下载)走 currentKeyOrNull(),
其余一律走 fetchXxx 方法。
解锁状态与宽限
| 模式 | 判定 | 宽限 |
|---|---|---|
trial(trialCodeEnabled=true) |
离线验签通过即解锁,无到期 | 不需要(本就离线) |
trial(trialCodeEnabled=false) |
存量票据:legacy-grace-until 未到即解锁 |
到期整体回落 unlocked:false;期内带 graceKind=legacyTrial + daysRemaining |
account |
POST {base}/api/license/verify-key 回 valid:true 即解锁 |
30 天未联网复验则 unlocked:false,提示联网重验;剩 ≤7 天带 graceKind=offlineReverify + daysRemaining |
| 非 local-mode | 团队服务器部署不设解锁门 | status() 恒 {unlocked:true, mode:"account", plan:"paid"},不带上面三个字段 |
必须账户登录(2026-08-18;2026-09-29 起启动不设门,见规则 1)
官方发布的桌面版把试用码这条解锁路关掉,解锁门只接受账户凭据(手机号/邮箱登录,或手工粘 awdk_ Key)。
2026-09-29 登录后置之后「解锁门」不再拦启动(launch 分流不读 unlocked),本节的票据与宽限规则仍决定
/api/license/status 的形状与账户入口的提醒行,但不再决定用户能不能进工作台。
配置项在 application-desktop.yml:
security.license.trial-code.enabled: false # 代码里的默认值仍是 true
security.license.trial-code.legacy-grace-until: "2026-09-30"
四条硬规则:
- 闸是默认值不是 DRM。 商业版 / 私有部署 / 自行构建改回
true即完全恢复,刻意不做防篡改。 2026-09-29 起(登录后置,dev-board#1046/#1047)启动不设门:launch 不读unlocked,不登录也能打开和使用 应用;AI / 广场付费 / 团队 / 手机同步看的是账户连接(4011),试用码代替不了。这个开关因此只剩两层作用: 登录框(AccountLoginDialog)是否提供「试用码 / 账户 Key」输入、存量mode=trial票据是否走过渡期。 README 里原来「自行构建改一行恢复离线试用」那一节已删并写明原因(门都没了,不存在「不给钥匙」)。 - 不要改用
security.local-mode: false来达成同一目的。 那一位是「这是单机桌面版」的判别位, 翻它会连带关掉解锁门本身、免费额度、平台 AI 通道、本机设备令牌与切站能力,并让/api/account/login自己锁死(走MachineAccountGuard,非 local-mode 要求先有 session)。 完整论证见docs/superpowers/specs/2026-08-18-desktop-account-required-design.md§1。 legacy-grace-until留空或格式非法一律按已到期处理,硬期限当天也算到期。安全侧默认: 配错一个日期不会变成永久宽限。daysRemaining/graceKind只在需要提醒时下发,不需要时status()的形状与过去一模一样。 顶栏右上角账户入口下拉里的提醒行(AccountEntry的noticeText,宿主project-overview.vue的accountNoticeText;2026-09-29 前是顶栏.trial-chip.grace-chip,dev-board#1047 并进下拉,入口位置见 dev-board#1062)与 unlock 页都只读 这两个字段,不自己算日期。
app-e2e 的连带约束:发版默认值下全新 user.home 起来的后端是 mode=none,套件没有任何办法
解锁它。冷启动跑法要往隔离 user.home 播一份存量 mode=trial 票据作起点(真实存在的过渡期状态),
不要改用 trial-code.enabled=true——那会让发版默认值反而没人测。配方在 run.mjs 头部。
在线校验的状态码分类是跨两个服务共用的一条判据(LicenseService.callVerifyKey 与 AccountService.handle):
4xx = 明确拒绝(清除本地授权 / UNAUTHORIZED),5xx 与超时 = 不可达(保留授权,走宽限)。
服务器故障不等于凭据失效——反过来判会让用户一断网就掉线。
Entitlement 命名空间
| 形式 | 例 | 谁定义 |
|---|---|---|
| 本地功能键 | app.unlocked、clipboard.unlimited、stage.unlimited、plan.pro(预留) |
FeatureCatalog 常量 |
| 付费 Skill | skill:<id> |
MarketPurchaseGate.skillFeature |
| 付费插件 | plugin:<id> |
MarketPurchaseGate.pluginFeature |
三者在官网 /api/account/entitlements 的同一个列表里。新增 SKU 要改三处:桌面 FeatureCatalog、
前端 useEntitlement.js 的 FEATURES、官网的权益命名表(官网生成兑换码时按同一张表做白名单校验,对不上就是发出去的码兑不了)。
skill: / plugin: 只能用 MarketPurchaseGate 的两个工厂方法构造,绝不拿广场条目 id 直接当 feature 查
(一个 id 叫 clipboard.unlimited 的 Skill 就能蹭到本地 SKU 的权益)。
GET /api/entitlements 的 features 与 catalog 是两个不同的问题,不许混:
features 只含已拥有的(「出现在列表里 = 已拥有」是这个字段名唯一自然的读法,
前端 useEntitlement 就是这么用的),catalog 才是目录全集带 enabled 标志。
账户型权益的失效有两条互不替代的路:联系不上官网 → 30 天宽限,超期整体回落「未拥有」
(不是「保持拥有」,否则永久离线 = 永久买断);官网明确拒绝(401/403 = Key 已吊销) → 立刻清缓存,
不吃宽限(拿宽限兜吊销等于用户止损后付费功能还能再用一个月)。缓存另外只在账户仍连接时生效——
删掉 account.json 权益一起失效,避免「手写一份缓存」变成免费解锁。
官网 API 契约(摘要)
权威文档是官网仓 doc/desktop-contract.md(人读版)+ scripts/contract-check.mts(机器可执行版),
改端点必须同时改那两处。下面只是桌面侧用到的部分,与权威文档冲突时以权威文档为准。
| 端点 | 鉴权 | 桌面调用点 |
|---|---|---|
POST /api/license/verify-key → {valid, plan} |
匿名 | LicenseService.callVerifyKey |
GET /api/account/me → {accountId, username, displayName, balanceCents, plan, createdAt, avatarUpdatedAt, displayNameIsDefault} |
Bearer | connect() 校验 Key、fetchProfile() 取余额、profileIdentity() 取身份视图 |
PATCH /api/account/profile {displayName?, bio?} → {displayName, bio} |
Cookie 或 Bearer | updateDisplayName();400 invalid_display_name |
POST /api/account/avatar multipart file → {avatarUpdatedAt} |
Cookie 或 Bearer | uploadAvatar();4xx too_large/invalid_image/unsupported_format |
DELETE /api/account/avatar → {avatarUpdatedAt:null} |
Cookie 或 Bearer | deleteAvatar() |
GET /api/avatar/{accountId}?v= |
匿名 | 头像地址由桌面端按 accountId + avatarUpdatedAt 拼好下发 |
GET /api/account/entitlements → {entitlements:[{feature, purchasedAt, orderId}]} |
Bearer | fetchEntitlements() |
GET /api/account/ledger?limit=50 → {entries:[...]} |
Bearer | fetchLedger(),桌面只挑 kind=ai_alloc 显示 |
GET /api/account/ai-usage(*) → {configured, hasKey, limitUsd, usageUsd, remainingUsd, keyMasked} |
Bearer | fetchAiUsage() |
POST /api/account/ai-key → {openrouterKey, limitUsd}(幂等) |
Bearer | PlatformAiChannel.fetch();409 no_allocation = 还没分配额度 |
GET /api/registry/{skills,plugins}/{id}/bundle、/file |
付费项要 Bearer 且已购,否则 402 | SkillMarketService / PluginMarketService |
(*) ai-usage 是唯一一条权威文档也没收录的端点:官网仓的 doc/desktop-contract.md 与
scripts/contract-check.mts 里都搜不到它,实现只在官网仓 app/api/account/ai-usage/route.ts
(那里还多返回 exchangeRate / marginMultiplier / disabled 三个字段;2026-09 起桌面端读前两个——ModelPriceDisplayService 用它们把模型选择器的价格折成「实付价」,官网新版还会多给 currency / exchangeRateSource / exchangeRateUpdatedAt,缺 currency 时按汇率推断币种、判不出就退回美元标价,绝不编造汇率;口径细节见 ai-chat.md「模型价格显示口径」)。
上表这一行的字段以该 route 为准;官网仓补齐这条端点 + contract-check 之前,改它两侧不会有任何护栏提醒。
三处与总 Spec §9 字面不同、以实现与官网契约为准:verify-key 的 plan 是 paid|free(不是 trial|paid);
ledger 返回 {entries:[...]}(不是裸数组);ai-usage 这个端点总 Spec 里压根没有。
另外 POST /api/account/redeem(兑换码 AWDG-XXXX-XXXX-XXXX)官网已实现但桌面端尚未接入——
桌面解锁门只认 AWD-T- 试用码与 awdk_ 账户 Key,兑换在官网账户页完成后靠权益同步生效。
ai-usage 要按「可能不存在」处理:拿不到只降级额度这一段(quotaAvailable:false),
余额与账本照常,前端显示「—」而不能把 0 当成剩余额度。
计费两套口径不得合并
TokenUsage.costSource 区分:platform(平台通道,cost 先留空、由 PlatformUsageAccountant 异步补真实扣费)
与 estimate(BYOK,单价表本地估算,不是账单)。GET /api/account/usage 把 local(本机统计)
与 platform(官网结算)分成两段返回,界面上分别标注,不做加总。
cost 为 null 原样保留 —— 对账未完成时显示「待结算」,绝不顶成 0。
已知地雷
-
客户端定期外发通道现有四条:entitlement 刷新(10 分钟陈旧阈值)、插件吊销名单(24h)、 启动 license 复验,以及匿名使用统计上报(TelemetryUploadService,启动 + 24h, POST {telemetry.ingest-url}/rollup 与 /events;开关在 system_setting 的 telemetry.rollup.enabled 默认开 / telemetry.events.enabled 默认关,设置页「数据统计」可关)。 README 隐私口径已随 PR 改为「匿名聚合统计默认开启可关闭」,动上报行为要同步 legal/PRIVACY.md。
-
响应带 code=4010 会被前端当成掉线。PR4-0 起
frontend/src/services/api.js只认 code=4010 判定未登录(已不做「登录/未授权/请先」中文子串匹配),命中就清本地会话 (浏览器端还跳登录页)。账户未连接、未分配额度、付费项未购买全是业务错误不是掉线, 必须走 code=1 信封、绝不带 4010。「这台电脑还没登录账户」自 2026-09-29 起有自己的码 4011 (AccountRequired,见核心契约「就地登录与 4011」):前端就地弹登录层,同样不清会话、不跳页。 两个码绝不能合并——4010 在浏览器端会跳登录页,把「点一下 OCR」变成被踢出去。 护栏:AccountServiceTest.accountMessagesDoNotLookLikeAuthErrors、两个 market 测试里的assertNotMistakenForLogout、GlobalExceptionHandlerAccountRequiredTest.neverFourTen、frontend/tests/account里「4011 分支不清会话不跳页」。 「跳登录页」这条链自己会喂自己(2026-08-17 现网事故,addin.aiworkdeck.com 打开即整页无限刷新):App.vue的导航拦截器给每一次跳转补一条ui.nav埋点,而/api/telemetry/event同样需要会话—— 4010 → reLaunch 登录页 → 埋点 → 又 4010 → 又跳,8 秒 100 次导航。两道闸缺一不可:utils/telemetryClient.js的 track 在浏览器态未登录时一条都不发(桌面 local-mode 免登录恒可发),services/api.js的 4010 分支在已经停在登录页时只清会话不再跳。 护栏frontend/tests/auth-redirect。往登录前的页面(launch/login/wizard)新加任何需要会话的请求, 或给导航拦截器再挂 fire-and-forget 调用,都要回看这条。 -
local-mode 与团队服务器模式行为差异是一整套,不是一个开关。
EntitlementService是按本机的 (数据源是~/.aiworkdeck的 license/account 状态,没有 userId 维度),团队服务器上权益恒为空集。 因此这些地方必须「非 local-mode 一律不限制 / 恒为正式版」:LicenseService.status/activate/deactivate、ClipboardService、StageQuotaService.limited()、LocalIdentityController三个端点、StorageLocationController(整块仅 local-mode 可用)。照着执行的话,团队案件库的每个成员都会被截到 20 条剪贴板 / 20 个文件且永远无法解锁。反过来,AccountController/EntitlementController/ 两个market/list必须要求登录——LocalModeAccessFilter在 server 模式整体短路, 而deploy/web/nginx.conf.example把/api/整段反代出去,漏检等于把账户隐私与「断开连接」暴露给匿名请求。 -
免登消掉了事实上的 CSRF 防线,靠
LocalModeAccessFilter补。免登前「必须带自定义头X-Session-Id」 会让跨源请求触发预检而被浏览器拦死;免登后不带任何头也能过鉴权,而CorsConfig只是不回显 ACAO、 并不阻断请求本身(multipart 表单提交这类简单请求照样执行到 controller)。过滤器补三条, 只在 local-mode 生效,server 模式一字不变:- 非安全方法(非 GET/HEAD/OPTIONS/TRACE)带了 Origin 且不在白名单 → 403(字面量
"null"也不在白名单)。 缺 Origin 一律放行——实测打包态渲染进程(file://+webSecurity=false)发出的 POST/multipart 完全不带 Origin, 开发态与 e2e 是localhost:5173/5174命中白名单。改这条判据前先确认这三种形态都还能通。 remoteAddr非回环 → 403(只信getRemoteAddr(),不看X-Forwarded-For)。- 出现任何反代痕迹头(
X-Forwarded-*/X-Real-IP/Forwarded)→ 403。这不是信任这些头, 而是把它们的出现当作「这台机器被反代了」的信号(团队版基线里 nginx 与后端同机,反代过来的 remoteAddr 恰是 127.0.0.1)。 过滤器 order 是HIGHEST_PRECEDENCE,排在CorsConfig之前——被拒的请求不该拿到任何 CORS 响应头。 Origin 白名单判据复用CorsConfig.isTrustedOrigin,而它第一行是if (allowAll) return true:security.cors.allow-all=true会把上面第一条闸整体打开。该项目前任何 yml 里都没配(默认 false), 属潜伏逃生门——排查「闸门为什么没触发」时先看它。POST /api/license/deactivate、POST /api/local-identity/select这类匿名 POST 全靠它兜着。
- 非安全方法(非 GET/HEAD/OPTIONS/TRACE)带了 Origin 且不在白名单 → 403(字面量
-
身份解析在多候选时不猜,交给用户选。老安装的库里常有多个历史账号,且
admin往往是空壳 (真机实测:admin 1 项目 0 文件,用户数据 6 项目 21 文件在hanzewei名下)。回落 admin 会让老用户 解锁后看到一个近乎空的工作区——数据没丢,只是全部不可见,比报错更难排查。localUserId()恒返回非 null(90 余处调用方,返回 null 等于全站 500),待选定时临时落在数据量最大的候选上 但不写持久化;真正的门在前端 launch 页读needsSelection分流到选择页。 选择结果存SystemSetting而不是~/.aiworkdeck/identity.json:存的是指向同一个库里 user 表的外键, 放进被指向的库才能与数据同生共死(还原旧库时指针跟着回退);license/account 描述的是机器授权,独立于库是刻意的。 测试账号前缀白名单(qa_bot_/claude-e2e/e2e_keepalive)保守到只排除脚手架自造账号, 真实账号一个都不许被误排。displayName改名收窄到username=admin——本机替用户改名会让他在选择页里认不出自己。 注意口径(2026-09-10 改写):真实账号的展示名不被LocalIdentityService改名,但会随官网刷新 (AccountIdentitySync,官网是唯一权威源)。这条不是「一个字都不动」。 -
免费额度只隐藏、不删数据。这是本领域最硬的一条红线:剪贴板是查询侧过滤(超出的行留在库里, 解锁后原样可见),缓存区是移入时拒绝(区内已有文件一个都不动,移出方向永不拦截)。 全 diff 里没有任何 delete/purge 路径被额度逻辑触发,任何「顺手清理超额记录」的改动都是回归。 同理,自选存储位置的迁移是复制 → 校验 → 换指针,原目录完整保留为备份,绝不先删后搬。
-
权益失效不等于把人锁在外面。
StorageLocationService.applyOnStartup是无条件的:Key 被吊销 / 断开账户 / 离线超宽限之后,数据仍在自选路径上照常读写。所以GET /api/storage/location与POST /api/storage/location/reset(恢复默认,只换指针不搬不删)不设权益闸,付费闸只留在 「改到新的自选位置」这一个动作上。设置页整块也不按权益隐藏,未解锁时显示UnlockHint与只读路径。 -
付费闸门在官网侧,本地缓存只用于标注。
entitlements.json未签名是有意接受的: 两个本地 SKU 的判定本就完全在本地执行,签名只是抬高门槛;真要钱的两条路各有服务端闸门 (付费 bundle 下载由官网 402 把关、平台 AI 额度由 OpenRouter 侧的 key limit 强制执行)。 因此MarketPurchaseGate.purchased()只做 UI 标注,缓存陈旧时按已购乐观放行、由 402 兜底—— 比在本地拦住一个真已购的用户更不容易出错。 -
平台 AI 通道取不到 key 时绝不静默回退 BYOK(会花用户自己的钱);未连接账户时该供应商展示但不可选, 点击给引导——隐藏会让用户发现不了,直接可选会拖到发消息才报错。 断开账户后降不降级按模式分(2026-09-29,dev-board#1046):团队服务器仍要
demotePlatformProvider()(那里没有登录弹层承接,不降级就是「界面显示平台通道选中、每条消息都报未连接账户」); 单机版(local-mode)不降级——「平台通道选中 + 未连接账户」正是全新安装的常态(DataInitializer就这么初始化),下一条消息由 4011 → 登录弹层承接,旧理由在单机版上已不成立;而降到 OLLAMA 会让同一台机器 出现两种默认值,官方版界面又没有 BYOK 入口,用户下一条消息会静默发给一个多半没装的本地模型。 判定在AccountSwitchCleanup.afterDisconnect()(security.local-mode),护栏AccountSwitchCleanupTest。 -
凭据类 JSON 一律用
AccountService.stateMapper()。Jackson 默认开着INCLUDE_SOURCE_IN_LOCATION, 解析失败时异常 message 带原文片段,而这些文件里都是明文密钥、解析失败点普遍log.warn(..., e.getMessage())—— 一次半截写入就能把 0600 的密钥复制进 0644 的日志。落盘后统一restrictPermissions()收敛到 0600(Windows 静默跳过)。 -
本地起官网联调要走回环 http。
AccountEndpoint.requireSecure强制 https 但放行回环 (localhost/127.0.0.0/8/::1),主机判据用 IPv4 字面量正则而非前缀——startsWith("127.")会放行127.0.0.1.evil.com。另外HttpAccountTransport固定 HTTP/1.1:JDK HttpClient 默认 HTTP_2, 对明文地址先发 h2c 升级,Next 开发服务器收到后不回字节,上层只看到「无法连接服务器」。 -
广场付费项的价格必须服务端自查,不信前端传来的
priceCents(等于让客户端决定闸门何时生效)。 价格没查到时本机有 Key 就照带 Bearer(分不清「真免费」与「付费但价格没查到」,不带的话后者官网必 402, 真已购用户会被反过来指控没付费)。normalizePrice有 ¥100,000 上限——官网写超 int 范围的值会被截断成 1215752191,原样展示就是 ¥12,157,521.91 的假价。 -
awdk 桥的映射键只认官网稳定
accountId,缺失时 MALFORMED 拒绝,绝不回落 username (可改名,改名后会凭空生出第二个 server 用户),也绝不把官网用户名绑到本服务器已有的 同名账号(等于账户接管)——首登一律新建awd_前缀用户。桥接建的是无密码账户: 口令列存UserService.EXTERNAL_ACCOUNT_MARK哨兵 + 随机料,login()见前缀直接拒绝; 动密码兼容逻辑时别把这个分支删了(删了哨兵检查后还有随机料兜底,但两道都在才算安全)。 -
锁定拒绝不计入失败计数。AuthController 里锁定检查(
checkLoginAttempt)与凭据校验 分属两个 try:锁定期内的轮询若也recordLoginFailure会把锁无限续期。同理 awdk-login 只有官网明确 401/403(UNAUTHORIZED)才计失败,网络不可达不消耗尝试次数。account-login同款,并且多排除一类:CONFLICT(phone_binding_required,过了补绑硬期限) 的用户凭据本来就是对的,锁他没有意义。 这两条匿名端点的限速维度是整条桥 + IP(ACCOUNT_LOGIN_RATE_KEY),刻意不按手机号/邮箱分桶: 云后端只是转发器,官网看到的来源 IP 恒为这台机器,按身份分桶等于允许一个 IP 换着号码无限试, 失败会原样打到官网的按 IP 计数(15 分钟 30 次即锁)上,最后被锁在门外的是本服务器的全体用户。account-login/send-code还把「尝试」记在出站之前(与/sms/send-code?scene=login相反—— 那条身后有用户名口令挡着),否则一串无效手机号就能免费换来等量对官网出站,IP 额度永远耗不尽。 -
首启向导不预选 AI 供应商(2026-08-21 起向导已无供应商单选,本条只剩后端
WizardController拒空activeProvider那一道仍在生效;历史如下)。曾经预选「本地 Ollama」,没装 Ollama 的用户一路点「完成设置」, 要到发第一条消息才收到 Connection refused(ChatModelFactory只在 OPENROUTER 下防回退 Ollama, 反向没有保护)。现在activeProvider初值是空串、由用户显式选,唯一的例外是「已连接账户且已分配额度」 时自动预选平台通道——用账户 Key 解锁的人买的就是这条通道,不该再被引导去配别家的 Key。 向导提交前的空值拦截在handleSubmit,后端WizardController也拒空activeProvider(两道都在才算数)。 取值本身的合法性(三档枚举)在AdminConfigController.toSettingsUpdates,两个入口共用。 -
向导里每一条「下一步」都必须能在向导里做完(规则仍然有效;下文的 Ollama 探测实例 已随 2026-08-21 官方版收敛从向导里删除,只留作此规则的历史例证)。平台通道曾经在未连接账户时置灰 + 提示「进入产品后在系统管理粘贴 Key」——那是死路:试用码解锁的用户在向导里无论如何都点不亮它, 只能先随便选一家凑合。现在
AWD_CLOUD恒可选,选中就地展开连接块(handleConnectAccount调POST /api/account/connect,与 admin 页onConnectAccount同链路:连接 → 重取状态 →refreshEntitlements(true)),已连接但没分配额度时给「前往官网分配额度 + 重新检查」两个动作。 两个前置条件缺任一时handleSubmit拦住提交并指出下一步——闸门从「不可选」挪到了「不可提交」, 不是取消了。 同一条规则的第二个实例(2026-08):选「本地 Ollama」时向导就地探测(GET /api/ai/ollama/probe, 选中即探一次 + 「重新检测」按钮),服务没起或目标模型没 pull 都由handleSubmit拦住, 并把ollama pull <模型名>与下载地址原样摆出来给用户复制。模型名取探测结果而不是前端写死, 因为它现在可以在 admin 里改(ai.ollama.modelName)。探测端点必须允许匿名调用—— 全新安装走向导时还没有任何会话。 -
全新安装必须先钉
system.wizard.completed=false(DataInitializer,仅 admin 不存在时写)。WizardController.isInitialized()在标记不存在时退回存量兜底「system_setting 非空即已初始化」, 而首启链上LocalIdentityService.commit()解析本机身份就会写下local.identity.selectedUserId一行——launch 页查身份在查向导之前,于是全新安装反而整个跳过首启向导(真机复现过: 解锁后直接进个人中心,ai.activeProvider一直空着,要到发第一条消息才发现)。 护栏config/DataInitializerTest:全新装写标记、存量库一个字都不许改(改了等于把匿名提交窗口重开)。 2026-09-29 补充:单机版(local-mode)紧接着由defaultToPlatformChannel()把向导收口 (AWD_CLOUD+completed=true,只在供应商为空且标记不是 true 时),见核心契约「首启初始化归后端」。 上面「存量库一个字都不许改」对团队服务器仍然成立;单机版存量库只在「从没选过供应商」时补这一行。 -
平台通道的 key 是「谁的额度」,多租户下缺身份必须报错而不是回落。 OpenRouter 的额度上限是 per-key 的,一把机器级 key 就是一个共享额度池——回落等于拿别人的钱花, 且对账差分会把 A 的消费记到 B 头上。
PlatformAiChannel.resolveOrThrow的四分支要背下来: local-mode 恒走机器级(一字不动);server + 已桥接 → per-user; server + 本实例存在任一绑定(multiTenant())时,未桥接用户与缺身份一律拒绝; server + 一个绑定都没有的团队服务器 → 机器级路径与改动前逐字一致。 第三条用multiTenant()而不是「非 local-mode」来收口,是为了让没人桥接的团队服务器 不会因为某处漏传身份就被打断——严格判据只落在真正的多租户实例上。 身份靠PlatformAiUserScope(ThreadLocal),池线程不会自动继承(继承的是创建者而非提交者), 每个跨线程提交点都要wrap;漏一处的后果被设计成「那条路报错」而不是「那条路记错账」。 新增 AI 入口(新的 @Async / executor.submit / runAsync)时必须一并接上,否则多租户下那条路取不到 key。 另外security.platform-key-secret在awdk-login-enabled=true时缺失即拒绝启动 (PlatformAiKeyCipher构造器)——明文降级是典型的潜伏逃生门,这里刻意不留。 -
切到平台通道有两个入口,跨境同意闸必须两个都设。同意(个保法第三十九条)的判定与 拒绝文案在
AdminConfigController.crossBorderBlockReason(ai, settings)(静态,与toSettingsUpdates同款),管理后台updateAdminConfig与首启向导WizardController.initialize都要调它。这条是踩出来的:闸门最初只在管理后台,而向导走的是toSettingsUpdates静态映射、 完全绕过闸门——偏偏向导才是用户选平台通道的主入口(AWD_CLOUD 恒可选,见地雷 15), 于是勾选框摆在了没人必须经过的页面上,同意形同装饰。 对应地,向导也必须自带同意勾选框(只加后端闸会让 AWD_CLOUD 在向导里不可提交,违反地雷 15); 两处初值都必须是未勾选——预先勾选的同意在个保法下无效。crossBorderConsent只在选平台通道时才进 payload:其余档位带false会把已有同意误撤回。 护栏:CrossBorderConsentTest(判定本身:版本作废、文案红线)CrossBorderConsentGateSharedTest(这道闸没有第二份实现、没有入口漏掉它)。
-
切站不是「换个域名」,是换了一个商业实体,清理表必须背下来(双主站设计 §2.4)。 删:
account.json、entitlements.json、platform-ai-key.json、license.json中 mode=account 的票据。 留:license.json中 mode=trial 的票据(试用码是内置公钥离线验签的,与站点无关; 抹掉等于把一个只想换站看看的试用用户直接踢回未解锁页)、storage-location、项目数据库。 AI 供应商降不降级与地雷 8 同一口径(2026-09-29 改):团队服务器调ChatModelFactory.demotePlatformProvider(); 单机版不降级,切站后的下一条 AI 消息由 4011 → 登录弹层承接(判据LicenseService.isLocalMode())。 护栏SiteSwitchServiceTest(含localModeKeepsPlatformProvider)。 切站的生效范围有意分成两段:账户/解锁门当场改指向,广场与统计上报下次启动才改 (在属性层固化),select因此回restartRecommended:true。 -
站点错配的文案不许指控用户的 Key(双主站设计 §2.6)。另一个站的 Key 拿到本站必然 「verify-key 回
valid:false」或「账户端点 401」,而 Key 是好的——只说「Key 无效或已被撤销」 会让用户去官网重新生成一把,回来再撞一次,且没有任何线索指向真正的原因。 多站形态下LicenseService.invalidKeyMessage与AccountService.unauthorizedMessage会点名当前站与另一站。信封照旧不得带 code=4010(地雷 1;文案写「切换站点后重试」是当年子串判定时代的遗留措辞,保留无害)。护栏SiteMismatchMessageTest。 刻意不做「拿同一把 Key 依次探测两个站」:awdk_是明文 bearer 凭据, 把它发给一个不是它签发方的服务器等于向第三方泄露一把有效凭据; 两站由同一团队运营不改变这个判断——今天成立不等于第二方托管实例出现后仍成立。 -
官网侧的反代绝不能对
/api/做 301/302。桌面端HttpAccountTransport用 JDK HttpClient, 默认Redirect.NEVER;一个 301 会被AccountService.handle判成「预期外状态」→ MALFORMED, 用户看到「官网返回了预期外的状态」而无从下手。这条在 workdeck.ai 的新加坡 vhost 上是真实风险: 那里按访客 IP 做 geo 分流,境内访客默认 301 回国内站,而「注册在国际站、人在境内」是合理场景。 必须给^~ /api/与^~ /update/加优先级更高的 location 绕开 geo 判断。 -
机器级缓存装的是账户级内容,换账号必须整套作废——而且不能只靠调用方记得。 连接账户有两个入口,作废动作原先只写在
AccountController.connect里; 解锁页粘awdk_走的LicenseController.activate只调了entitlementService.refreshAsync(), 于是从解锁页换一个没充值的新账号进来时,上一个账号的平台 AI 密钥、已购权益、用量基线 三样原封不动留着:新账号照样能花上一个账号的 OpenRouter 额度,也继承了它买过的付费项。 偏偏解锁页才是主入口(用账户 Key 解锁的人走的就是那条路)。 修法是两层都要在:① 动作收进AccountSwitchCleanup,两个入口共用; ② 平台密钥缓存自己记owner指纹,归属对不上就丢弃重取—— 「记得清缓存」是会忘的,「归属对不上就不认」不会。 存量缓存文件没有owner,读到 null 视为不匹配、重取一次重新绑定;平台通道本来就要联网打 OpenRouter,不存在「离线靠这份缓存续命」的场景。护栏:PlatformAiKeyOwnershipTest、LicenseControllerEditionTest.activateReportsAccountConnected。 -
「没充值不能用」不能只靠取 key 那一刻的 409。官网对零余额账户在
Truncated - read the full file at https://github.com/AI-WorkDeck/aiworkdeck/blob/61ae398f4d00d42ce4743c7c6434df7c31106e38/.claude/agents/licensing-billing.md.
