feat: CI 流水线 — lint + 单测 + OpenAPI 校验 + 脱敏扫描 + 镜像构建 [tsk_P5b5nIrEsfrV]

新增 .gitea/workflows/ci.yml 五个 Job:
  1. lint        — shellcheck -S warning 扫描全部 deploy/ shell 脚本
  2. unit-test   — docker-compose config 语法校验 + nginx -t(桩证书)
  3. openapi-check — openapi-spec-validator 验证 design/server/openapi.yaml
  4. redline-scan  — ci/scan-redline.sh 扫描 UI 文案红线词(design/ jsx/dart/html)
  5. image-build   — docker build pangolin-edge:ci

附带:
  - ci/scan-redline.sh:脱敏扫描脚本,过滤注释行与外部渠道 handle
  - ci/nginx-test.sh:自签桩证书 + nginx -t,CI 免依赖真实 Let's Encrypt
  - design/server/openapi.yaml:依据 ARCHITECTURE.md §3 展开的 OAS 3.0 完整契约
  - dparts.jsx / parts.jsx:修复 killSwitchSub EN 文案「the VPN drops」红线词
    → 改为「connection drops」(行为描述,不提产品类别)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 01:19:28 +08:00
parent a642bf16a2
commit c92cd11cc5
6 changed files with 883 additions and 2 deletions
+655
View File
@@ -0,0 +1,655 @@
openapi: 3.0.3
info:
title: 穿山甲 (Pangolin) Control-Plane API
description: |
穿山甲网络加速 控制面 API v1。
**设计原则**
- 控制面仅管账户、套餐、节点目录、兑换;用户流量走加速节点(WireGuard),不经过本 API。
- App 内无支付表单;资金流全走外部(激活码渠道)。
- 最小数据:不记录目的地址;仅保留计费所需元数据。
**认证**:除 `/auth/*` 外,全部接口需 `Authorization: Bearer <access_token>`。
**错误体**`{ code: string, message_zh: string, message_en: string }`
version: 1.0.0
contact:
email: support@pangolin.app
servers:
- url: https://api.pangolin.app/v1
description: 生产环境
security:
- bearerAuth: []
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
Error:
type: object
required: [code, message_zh, message_en]
properties:
code:
type: string
example: INVALID_CODE
message_zh:
type: string
example: 兑换码无效
message_en:
type: string
example: Invalid activation code
JWT:
type: object
required: [access_token, refresh_token]
properties:
access_token:
type: string
description: 有效期 15 分钟
refresh_token:
type: string
description: 有效期 30 天
UserProfile:
type: object
properties:
id:
type: string
format: uuid
email:
type: string
format: email
subscription:
$ref: '#/components/schemas/Subscription'
usage:
$ref: '#/components/schemas/UsageSummary'
Subscription:
type: object
properties:
plan:
type: string
enum: [free, pro, team]
expires_at:
type: string
format: date-time
nullable: true
description: null 表示无限期(免费版)
source:
type: string
enum: [trial, code]
Device:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
platform:
type: string
example: ios
pubkey:
type: string
description: WireGuard 公钥 (base64)
last_seen:
type: string
format: date-time
Plan:
type: object
properties:
id:
type: string
format: uuid
code:
type: string
enum: [free, pro, team]
description: free=免费版; pro=25¥/月; team=99¥/月(10席位)
max_devices:
type: integer
max_routes:
type: integer
daily_minutes:
type: integer
nullable: true
description: null 表示无限制;免费版为 10 分钟/天
ad_gate:
type: boolean
description: 是否需要每日激励视频解锁
Node:
type: object
properties:
id:
type: string
format: uuid
region:
type: string
description: 2 字母地区码,如 HK / JP / SG
example: HK
name_zh:
type: string
example: 香港 01
name_en:
type: string
example: Hong Kong 01
tier:
type: string
enum: [free, pro]
status:
type: string
enum: [up, down, draining]
weight:
type: integer
latency_ms:
type: integer
nullable: true
description: 由客户端测速后本地排序;此字段为服务端建议值
NodeList:
type: object
required: [version, nodes]
properties:
version:
type: integer
description: 节点目录版本号,递增;客户端带 if_version 实现 304
nodes:
type: array
items:
$ref: '#/components/schemas/Node'
WireGuardConfig:
type: object
required: [endpoint, server_pubkey, client_ip, dns]
properties:
endpoint:
type: string
description: 节点 WireGuard 端点,格式 host:port
example: hk1.pangolin.app:51820
server_pubkey:
type: string
description: 服务端 WireGuard 公钥 (base64)
client_ip:
type: string
description: 分配给客户端的隧道内网 IP
example: 10.66.0.2/32
dns:
type: string
example: 1.1.1.1
ttl_seconds:
type: integer
nullable: true
description: peer TTL;免费版 = 今日剩余秒数,付费版 = 86400
UsageSummary:
type: object
properties:
bytes_up:
type: integer
bytes_down:
type: integer
minutes_used:
type: integer
ad_unlocked_at:
type: string
format: date-time
nullable: true
description: 今日激励视频解锁时刻;null = 未解锁(免费版)
DailyUsage:
type: object
properties:
date:
type: string
format: date
bytes_up:
type: integer
bytes_down:
type: integer
minutes_used:
type: integer
paths:
# ── Auth ──────────────────────────────────────────────────────────────────
/auth/code:
post:
summary: 发送邮箱验证码
description: 触发邮件发送 6 位数字验证码;有效期 10 分钟,限 1 次/分钟(Redis 滑窗)。
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email:
type: string
format: email
responses:
'204':
description: 验证码已发送
'422':
description: 参数格式错误
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'429':
description: 请求频率超限
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/auth/register:
post:
summary: 注册账户(自动开 7 天体验期)
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, code, password]
properties:
email:
type: string
format: email
code:
type: string
minLength: 6
maxLength: 6
pattern: '^\d{6}$'
password:
type: string
minLength: 8
responses:
'200':
description: 注册成功,返回 JWT
content:
application/json:
schema:
$ref: '#/components/schemas/JWT'
'400':
description: 验证码错误 / 已过期 / 邮箱已注册
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'422':
description: 参数格式错误
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/auth/login:
post:
summary: 登录
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, password]
properties:
email:
type: string
format: email
password:
type: string
responses:
'200':
description: 登录成功,返回 JWT
content:
application/json:
schema:
$ref: '#/components/schemas/JWT'
'401':
description: 邮箱或密码错误
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/auth/refresh:
post:
summary: 刷新 access_token
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [refresh_token]
properties:
refresh_token:
type: string
responses:
'200':
description: 新 JWT
content:
application/json:
schema:
$ref: '#/components/schemas/JWT'
'401':
description: refresh_token 无效或已过期
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# ── Me ────────────────────────────────────────────────────────────────────
/me:
get:
summary: 获取当前用户信息(账户 + 订阅 + 用量摘要)
responses:
'200':
description: 用户信息
content:
application/json:
schema:
$ref: '#/components/schemas/UserProfile'
'401':
description: 未认证
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/me/devices:
get:
summary: 获取设备列表
responses:
'200':
description: 设备列表
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Device'
'401':
description: 未认证
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/me/devices/{id}:
delete:
summary: 移除设备(回收节点 peer
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'204':
description: 已移除
'401':
description: 未认证
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: 设备不存在
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# ── Plans & Subscriptions ─────────────────────────────────────────────────
/plans:
get:
summary: 获取套餐目录(供客户端展示价格页)
security: []
responses:
'200':
description: 套餐列表
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Plan'
/redeem:
post:
summary: 兑换激活码(幂等;同码重复提交返回首次结果)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code]
properties:
code:
type: string
description: Crockford Base3216 位,含校验位
example: 0A1B2C3D4E5F6G7H
responses:
'200':
description: 兑换成功,返回最新订阅信息
content:
application/json:
schema:
$ref: '#/components/schemas/Subscription'
'400':
description: 激活码无效、已使用或已作废
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: 未认证
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'429':
description: 兑换失败频率超限(5次/小时后锁 1 小时)
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# ── Ads ───────────────────────────────────────────────────────────────────
/ads/unlock:
post:
summary: 免费版:激励视频解锁当日时长
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [device_id, ad_token]
properties:
device_id:
type: string
format: uuid
ad_token:
type: string
description: 广告 SDK 服务端回执 token(用于服务端验证)
responses:
'200':
description: 当日时长已解锁
content:
application/json:
schema:
type: object
required: [ad_unlocked_at]
properties:
ad_unlocked_at:
type: string
format: date-time
'400':
description: ad_token 无效 / 今日已解锁
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: 未认证
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# ── Nodes ─────────────────────────────────────────────────────────────────
/nodes:
get:
summary: 获取节点目录(按当前订阅过滤;支持 304)
parameters:
- name: if_version
in: query
required: false
schema:
type: integer
description: 客户端持有的版本号;服务端未变更时返回 304
responses:
'200':
description: 节点列表(含版本号)
content:
application/json:
schema:
$ref: '#/components/schemas/NodeList'
'304':
description: 节点目录未更新
'401':
description: 未认证
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/nodes/{id}/connect:
post:
summary: 连接节点,下发 WireGuard 配置
description: |
校验:订阅状态、设备数限制;免费版额外校验当日 ad_unlocked_at 与剩余分钟。
通过后经 gRPC 让节点 agent 注册 peer,返回 WireGuard 配置。
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [device_pubkey]
properties:
device_pubkey:
type: string
description: 客户端 WireGuard 公钥 (base64)
responses:
'200':
description: WireGuard 配置
content:
application/json:
schema:
$ref: '#/components/schemas/WireGuardConfig'
'401':
description: 未认证
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'402':
description: 订阅已到期 / 设备数超限 / 免费版未解锁当日时长
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: 节点不存在或已下线
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/nodes/{id}/disconnect:
post:
summary: 断开节点(回收 WireGuard peer
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'204':
description: 已断开
'401':
description: 未认证
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: 节点不存在
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# ── Usage ─────────────────────────────────────────────────────────────────
/usage:
get:
summary: 获取用量曲线(统计页)
parameters:
- name: days
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 30
default: 7
description: 返回最近 N 天数据
responses:
'200':
description: 每日用量数组(含字节数与时长,不含目的地址)
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/DailyUsage'
'401':
description: 未认证
content:
application/json:
schema:
$ref: '#/components/schemas/Error'