Imported from Yeqingky/mcsm-redeem (
AGENTS.md). Install upstream withnpx skills add Yeqingky/mcsm-redeem. Copyright stays with the author.
mcsm-redeem 后端开发规范
本文件供 AI 与开发者接手本仓库时阅读。开始任务前必须完整阅读本文件。 每次代码变更后必须同步更新本文件:新增/删除文件、表结构、环境变量、业务规则、实现思路有变时,在对应章节补充,保持与代码一致。
仓库职责
MCSManager 卡密兑换系统后端:Go + SQLite 单进程 API。前端在独立仓库 mcsm-redeem-web。本仓库只处理后端代码、.env.example、docker-compose.yml、Dockerfile、GitHub Actions 和本仓库 README。
代码导航
- 入口:
cmd/server/main.go(CLI 参数、构建信息、信号监听、优雅停机、迁移结果日志) - 配置及
.env优先级:internal/config/config.go(系统环境变量 >.env> 默认值) - HTTP 路由、鉴权、兑换流程、到期时间、限流:
internal/httpapi/server.go(BuildInfo为编译期注入的后端构建信息) - MCSManager 调用:
internal/integrations/mcsm.go(CreateUser/FindUser/DeleteUser/CreateInstance/Assign等) - 人机验证(
internal/captcha/,Verifier接口 + cap/turnstile/hcaptcha;配置存数据库captcha_config单行表,管理面板"系统设置 → 验证码"修改) - FIFO 任务队列、TTL、Stop:
internal/queue/queue.go(Result含Password仅存内存,TTL 后丢弃) - SQLite 结构、版本化迁移、卡密状态、兑换进度、统计:
internal/store/store.go(见下文"数据库版本化") - 卡密筛选、分页、排序:
internal/store/codes_query.go - 套餐数据结构与持久化:
internal/store/sku.go(skus表,FirstSKUID=1000起AUTOINCREMENT;daemon_refs为{panelId, daemonId}列表) - 多面板持久化:
internal/store/panel.go(mcsm_panels表、Panel/PanelInput、validatePanel校验) - 人机验证配置持久化:
internal/store/captcha_config.go(captcha_config表) - 日志:
internal/logger/logger.go
CLI 参数
./mcsm-redeem # 不带参数:从当前目录 .env(回退 ../.env)启动 API 服务
./mcsm-redeem -v # 打印版本信息后退出
./mcsm-redeem -h # 打印帮助后退出
- 版本参数别名:
-v/--version/-version/version;帮助别名:-h/--help/-help/help。 - 未知参数输出用法到 stderr 并以退出码
2结束,不会启动服务。 - 版本信息为多行 KV:
version/commit/built/go,其中前两项与built由编译期注入。 - 注入变量:
cmd/server/main.go的buildVersion/buildCommit/buildTime,默认值dev/unknown/unknown。- 二进制(
.github/workflows/release.yml):-ldflags "-X main.buildVersion=... -X main.buildCommit=... -X main.buildTime=..."。 - 镜像(
Dockerfile):ARG VERSION/ARG COMMIT,工作流通过build-args传入。
- 二进制(
- 新增 CLI 参数时同步更新
usage常量与本节说明。
构建信息
cmd/server/main.go的buildVersion/buildCommit/buildTime由编译期-ldflags "-X main.buildVersion=..."注入,默认dev/unknown/unknown。- 启动时包装成
httpapi.BuildInfo传给httpapi.New,两种读取方式:./mcsm-redeem -v:本机直接输出。GET /api/admin/version:返回{version, commit, built}。
- 该端点必须保持鉴权(套
s.auth):公开暴露会让攻击者批量指纹扫描已知存在安全漏洞的版本。未登录时返回401 {"error":"未登录"},与其它管理接口完全一致,无法据此判断端点是否存在。 - 前端关于页依赖此端点;后端低于首次引入该端点的版本时返回 404,前端需优雅降级而非报错。
数据库版本化
版本号
- 数据库版本号 = 触发结构变更的发布 tag(如
v0.3.2-fix1、v1.0.0、v1.1.0),字符串形式存schema_meta(version TEXT)单行表(id=1)。 - 无结构变更的纯 fix 发布不提升数据库版本号,不在迁移表中出现。
- 源码
const schemaVersion = "v1.1.0"为当前程序支持的最新版本;const schemaVersionLegacy = "v0.3.2-fix1"为首个版本化标记。
迁移表
var schemaMigrations = []schemaMigration{{to: "vX.Y.Z", migrate: func(tx *sql.Tx) error}}按to升序排列,每个条目对应一次结构变更。- 历史链(已实现,可直接作为新增迁移的参考):
v0.1.0 months(1/3) → days(30/90),新增 used_at/username/password/ip_address v0.2.0 SKU 支持:skus 表、sku_id/created_at/status、SKU 关联回填 v0.3.0 instance_id + rate_limit_config v0.3.2 captcha_config v1.0.0 移除明文 password 列 v1.1.0 多面板多节点:mcsm_panels 表、redeem_codes.daemon_id、skus.daemon_refsv0.3.2-fix1为首个版本化标记本身,无结构变更,不在表内单独占条目(v0.3.2与v0.3.2-fix1视为同一 DB 结构)。 - 新增迁移时:在
schemaMigrations末尾追加一步(to为新发布 tag),把schemaVersion提升到同一 tag,迁移函数通过PRAGMA table_info探测是否需要变更,无需变更直接return nil(幂等,支持任意跳级)。
启动流程
readSchemaVersion() → storedVersion
├─ storedVersion == "" && !codesTableExists() → 全新库:直接建 v1.1.0 结构,写 schemaVersion
├─ storedVersion == "" && codesTableExists() → 旧库:inferLegacyVersion() 推断历史版本
│ ├─ 推断为 v0.0.2 (months) → bootstrapLegacySchema() 一次性转换后写 v0.3.2-fix1,再 migrateToLatest(v0.3.2-fix1)
│ └─ 推断为 v0.1.0/v0.2.0/v0.3.0 → 写推断版本,migrateToLatest(推断版本) 依次执行历史迁移
└─ storedVersion != "" → 版本化库:migrateToLatest(storedVersion) 定位式升级
migrateToLatest()先比较storedVersion > schemaVersion则拒绝启动;否则定位第一个to > storedVersion开始执行,每步在独立事务内migrate(tx)+UPDATE schema_meta SET version=?,失败回滚并保持旧版本号,下次启动可重试。inferLegacyVersion()依据PRAGMA table_info(redeem_codes)与sqlite_master探测months/days、sku_id/status/created_at、instance_id、rate_limit_config、captcha_config、password列/表是否存在,返回最匹配的历史 tag。compareSchemaVersions()解析vX.Y.Z/vX.Y.Z-fixN(前导v可选),按major > minor > patch > fix比较。
历史旧库兼容
bootstrapLegacySchema()保留为v0.0.2(months)旧库的一次性探测转换路径,内部复用ensureCodeColumns/migrateMonths/ensureSKUReferences;转换后视为v0.3.2-fix1结构,再由版本化链处理到当前版本。readSchemaVersion()兼容开发期写入的整数版本号1(v0.3.2-fix1开发阶段):遇到INTEGER类型的version列时DROP TABLE schema_meta重建为TEXT并按无版本号旧库重新推断。- 未来新增迁移时,
bootstrapLegacySchema不需改动,历史旧库通过inferLegacyVersion→ 版本化链自动覆盖。
必须保持的业务规则
卡密
- 卡密必须是标准带短横线 UUID 文本(
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)。 - 状态:
0未使用、1已使用、2已禁用、3已锁定(兑换中);其他值按已禁用处理。 - 已锁定卡密不能通过管理端启用或禁用。
- 兑换前必须用 SQLite 条件
UPDATE原子地把0锁定为3。 - MCSManager 失败 → 卡密恢复
0;MCSManager 成功但写“已使用”失败(Consume落库失败)→ 保持3,避免重复操作。此时会 best-effort 清理刚创建的 MCS 用户(FindUser→DeleteUser,实例保留待重试复用),清理失败仅日志,不掩盖原错误;queue任务标failed且不返回密码,避免旧密码泄露后用户被删导致无法登录。 SaveUser/SaveInstance落库失败(创建用户/实例后写进度失败)→ 直接返回错误,卡密保持3,此时会 best-effort 清理刚创建的 MCS 资源(DeleteUser/DeleteInstance),失败则成孤儿需在 Panel 手动删除(Consume的清理不覆盖此阶段,属历史设计,需人工核对status=3的卡密对应 MCS 用户/实例后再unlock)。- 进程崩溃遗留的
3或Consume失败遗留的3不会自动重置,需管理员核对 MCSM 侧实际用户/实例后再手动UPDATE redeem_codes SET status=0或禁用/启用处理。 - 启用/禁用只改状态字段,不得清空
username/instance_id等兑换进度字段(password自v1.0.0起已移除,不再持久化)。
兑换流程(断点续传)
- 开通:创建用户成功 →
SaveUser写库(仅username,v1.0.0起不再存储密码)→ 创建实例成功 →SaveInstance写库(instance_id)→ 绑定(实例已在用户列表则跳过Assign)→Consume写username/ip_address并置status=1。 - 密码自
v1.0.0起不落库:queue.Result.Password仅存内存,随GET /api/tasks/{id}在TASK_TTL(默认 10m)内返回给兑换用户,TTL 后丢弃。 - 中途失败解锁后用户名进度保留,重试时若
username已存在则FindUser→DeleteUser(DELETE /api/authbody["uuid"],反代需透传 DELETE body,见下文“运维”)删除旧用户 →CreateUser以新密码重建同名用户,再继续后续步骤。实例保留在节点上,不受删除影响,不得重复创建用户或实例。 - 续费只接受实例 ID,不验证实例归属(产品设计)。
- 到期时间 = "基准日期 + 卡密天数"当天的 00:00,时区由
TZ决定。 - 续费时原到期时间在未来则以其为基准,否则以当前日期为基准。
用户名与实例名前缀
- 环境变量只填原始前缀(如
mc、td),程序拼接-(mc-随机值)。 - 不要对前缀做自动检查、去重或兼容处理。
套餐
- 套餐 ID 从
1000起AUTOINCREMENT永久递增,删除不重用。 - 删除是软删除;已绑定卡密仍可兑换,但不能再导入新卡密。
- 天数永远不属于套餐;MCS 实例配置保存在套餐
config_json。 - 后端不限制镜像必须已在节点存在(前端下拉选择不是安全边界)。
面板与节点
- 多面板记录在
mcsm_panels,api_key明文存储,仅经auth保护的/api/admin/panels*可读写,列表接口原样返回apiKey。 skus.daemon_refs记录该套餐可用的{panelId, daemonId}列表(写入时按panelId:daemonId去重,要求panelId > 0且daemonId非空)。daemon_refs为空数组视为全选:兑换时聚合所有面板的节点作为候选。- 选节点
selectDaemonForSKU():随机打乱候选后依次探活(对节点所属面板调ListDaemons,10s 超时,要求节点存在且available),全部失败则报「所有节点探活失败」。 - 候选为空时回退
.env单面板配置(MCSM_API_URL/MCSM_KEY/MCSM_DAEMON_ID),因此mcsm_panels允许为空,旧部署不需配置面板即可兑换。 - 兑换成功把所选节点写入
redeem_codes.daemon_id;已有实例时优先按该字段在各面板中定位原节点复用,找不到才重新随机选。 - 面板删除是硬删除,不清理套餐
daemon_refs中的失效引用(探活失败会自然跳过)。
限流
- 参数存
rate_limit_config单行表:enabled、登录/兑换窗口与上限、ban_seconds。 - 默认关闭;开启/关闭只改开关,开启不重置参数;关闭清空内存封禁记录。
- 达到上限封禁
ban_seconds,封禁期全 429;成功操作重置该 IP 计数。 - IP 名单只存内存(
rateLimiter),重启即清空。
统计与分页
GET /api/admin/codes支持limit/offset、status/skuId/query、sort(createdAt/usedAt)与order(asc/desc)。- 排序时 NULL 的
used_at必须恒排最后(ORDER BY (used_at IS NULL) ASC)。 GET /api/admin/codes/stats按TZ时区在 Go 侧归天统计,不在 SQL 里做时区转换。
运维:反代对 DELETE body 的要求
DeleteUser依赖DELETE /api/auth带 JSON body["uuid"](MCSM Panel 约定)。部分 Nginx/WAF 默认丢弃DELETEbody,表现为DeleteUser持续失败、provision重试分支返回“清理上次未完成的用户失败”且卡密保持锁定。- 必须确保链路透传 body:
proxy_pass_request_body on;(Nginx 默认on,若显式off需改回)、proxy_set_header Content-Length $content_length;、client_max_body_size放开;WAF/安全网关放行DELETE+Content-Type: application/json。 - 无法透传时只能让后端直连 Panel,或在反代层将
DELETE /api/auth转为透传。POST /api/auth/delete并非 MCSM 原生接口,不要自行降级。
安全
- 管理会话 Cookie:
HttpOnly+SameSite=Strict,HTTPS 前端强制COOKIE_SECURE=true。 - CORS 只放行
FRONTEND_ORIGIN精确值。 CLIENT_IP_HEADER默认X-Forwarded-For,支持自定义请求头名;程序不验证 CDN/代理是否可信,IP 限流与卡密使用记录可能被伪造 IP 绕过,这是设计之中,由 CDN/反代负责覆盖请求头和限制源站访问。- 续费不验证实例归属,这是产品设计,不要改动。
- 人机验证默认关闭:配置存储在数据库(
captcha_config单行表),管理面板"系统设置 → 验证码"中开启/关闭和修改,运行时即时生效,不读取任何环境变量。启用后验证服务不可用时登录与兑换全部失败(fail-closed),前端也发不出有效请求,属预期行为。前端经公开端点GET /api/captcha/config获取配置。 - 密码安全:
v1.0.0起redeem_codes.password列已删除,已有明文密码随v0.3.2-fix1 → v1.0.0迁移重建表一并清除;新兑换的密码仅在内存队列中随任务结果返回,不落库。旧版数据库升级到v1.0.0会自动清除历史密码。 - 不在日志、提交、文档或回复中输出真实的人机验证 Secret、MCSManager API Key、管理员密码或用户密码。
- 两个仓库的
.env都已忽略,含真实本地配置,不要提交或在工具输出中展开。
数据库表
schema_meta:版本单行表(id=1, version TEXT)。redeem_codes:卡密。字段code(UUID 主键)、days、sku_id、created_at、used_at、status、username、ip_address、instance_id、daemon_id(兑换进度;password自v1.0.0起移除)。skus:套餐。id(从 1000 起AUTOINCREMENT,删除不重用)、alias、config_json(实例配置)、created_at、deleted_at(软删除)、daemon_refs(JSON 数组[{panelId,daemonId}],空数组表示全选)。mcsm_panels:MCSManager 面板。id(AUTOINCREMENT)、name、api_url、api_key、created_at;无软删除。rate_limit_config:限流配置单行表(id=1)。captcha_config:人机验证配置单行表(id=1,provider为空表示未启用)。
开发与验证命令
gofmt -w <修改的 Go 文件>
go test ./...
go vet ./...
go build ./...
受限环境 Go 缓存可能只读,可用任务专用缓存:
GOCACHE=/tmp/mcsm-redeem-go-cache go test ./...
GOCACHE=/tmp/mcsm-redeem-go-cache go vet ./...
internal/httpapi 的测试会在回环地址创建临时 HTTP 服务器,沙箱环境中可能需要授权运行。
交付前执行 git diff --check 和 git status --short。
Git 与发布
- 提交身份:
git -c user.name="YeqingKy" -c user.email="me@yeqingky.com" commit ... - 提交标题保持简短,详细更新放在提交正文;只在用户明确要求时提交、推送或创建 Tag。
- 推送用 WSL 内
/usr/bin/git,不要手动调用git.exe;内部自动调用 Windows OpenSSH。 - Tag 使用 annotated Tag;Tag 推送触发测试、双架构二进制、多架构镜像和 GitHub Release。
- 数据库版本号与发布 tag 一致,每次涉及表结构变更的发布必须同步提升
internal/store/store.go:const schemaVersion并在schemaMigrations追加迁移;纯 fix 发布不提升版本号、不新增迁移。 - 前端仓库
mcsm-redeem-web是独立仓库,前端改动要在那边提交,Git 命令不要跑错仓库。
