9 Commits

Author SHA1 Message Date
25cacf0bca 完善节点筛选机制 2026-06-12 16:51:08 +08:00
513fe78815 实现手动 proxy 同步接口 2026-06-11 17:52:21 +08:00
ebac8042ea 重构提取逻辑,新增 area 表 2026-06-11 10:10:07 +08:00
dd482dd6b0 优化通道处理 2026-06-10 10:24:05 +08:00
c5453557ae 实现 gost 网关 2026-06-09 15:44:09 +08:00
b00782b3f6 实现文件上传 2026-06-06 17:22:01 +08:00
1b39b2d411 新增 api 文档 2026-06-01 18:37:13 +08:00
7f30b6be4e 补全权限数据 & 优化 router 代码结构 2026-06-01 16:23:16 +08:00
0dfbbe5939 实现文章与分组管理 2026-06-01 15:46:43 +08:00
45 changed files with 16950 additions and 1428 deletions

View File

@@ -1,6 +1,9 @@
# 应用配置
RUN_MODE=development
DEBUG_HTTP_DUMP=false
UPLOAD_DIR=./data/uploads
UPLOAD_PUBLIC_BASE_URL=
ARTICLE_UPLOAD_MAX_BYTES=5242880
# 数据库配置
DB_HOST=127.0.0.1

2
.gitignore vendored
View File

@@ -19,3 +19,5 @@ scripts/*
!scripts/env/dev/
!scripts/pre/
!scripts/sql/
*/uploads/

View File

@@ -1,14 +1,16 @@
## TODO
---
- edge.area_id 可为空,代表节点无固定地区
- 后台展示 mac, ip:port实际地区
用反射实现环境变量解析,以简化函数签名
上传文件平铺到 uploads不分子文件夹
错误提示增强,展示整链路信息
交易信息持久化
订单关闭问题,在前端关闭窗口后直接调用了全部订单接口,应改成先确认再关闭
- 取消订单接口改成只允许管理员调用
- 新增关闭订单接口,关闭订单的逻辑是先尝试完成,如果订单未支付则取消订单
@@ -20,27 +22,33 @@
冷数据迁移方案
## 开发环境
## 开发流程
### 更新表结构的流程
### 新建数据表流程
1. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意:
1. 创建 model 文件
2. 将 model 按照格式添加声明到 `cmd/gen/main.go`
3. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意:
- 先写 drop table if exists 语句,确保脚本可以幂等执行
- 编写 create table 语句,按需添加审计字段和软删除字段 (created_at, updated_at, deleted_at)
- 为有必要的字段添加索引
- 为数据表及其字段添加注释
- 在文件末尾创建数据表流程全部结束后,为字段添加外键
2. 建议用数据库工具检查差异并增量更新,或者手动增量更新
3. 创建 model 文件,并将其添加到 gen 代码中
4. 生成查询文件
4. 调用 `go run ./cmd/gen/main.go` 生成查询文件
### 权限管理
### 更新数据表流程
`web/core/scopes.go` 下定义了系统所有静态权限
1. 更新 model 文件
2. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意:
- 先写 drop table if exists 语句,确保脚本可以幂等执行
- 为有必要的字段添加索引
- 为数据表及其字段添加注释
- 在文件末尾创建数据表流程全部结束后,为字段添加外键
3. 调用 `go run ./cmd/gen/main.go` 更新查询文件
新增系统权限需要在数据库中配套添加权限
### 新增接口或修改接口权限
前端也需要新增配套权限定义
1.`web/core/scopes.go` 下声明权限常量。通常格式为 `Model:Action:SubAction`,例如 `User:Create``User:Delete``User:Update:Password`
2.`scripts/sql/fill.sql` 文件的权限区域添加或修改权限条目
## 业务逻辑

View File

@@ -35,6 +35,9 @@ func main() {
m.Admin{},
m.AdminRole{},
m.Announcement{},
m.Area{},
m.Article{},
m.ArticleGroup{},
m.Bill{},
m.Channel{},
m.Client{},

View File

@@ -23,7 +23,7 @@ services:
restart: unless-stopped
postgres-migrate:
image: postgres:17
image: postgres:17.7
environment:
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
@@ -40,6 +40,14 @@ services:
depends_on:
- redis
gost:
image: gogost/gost
command: >
-api test:test@:9700
ports:
- "9700:9700"
restart: unless-stopped
volumes:
postgres_data:
redis_data:

File diff suppressed because it is too large Load Diff

23
pkg/env/env.go vendored
View File

@@ -18,12 +18,15 @@ const (
)
var (
RunMode = RunModeProd
LogLevel = slog.LevelDebug
TradeExpire = 15 * 60 // 交易过期时间,单位秒。默认 900 秒15 分钟)
SessionAccessExpire = 60 * 60 * 2 // 访问令牌过期时间,单位秒。默认 2 小时
SessionRefreshExpire = 60 * 60 * 24 * 7 // 刷新令牌过期时间,单位秒。默认 7 天
DebugHttpDump = false // 是否打印请求和响应的原始数据
RunMode = RunModeProd
LogLevel = slog.LevelDebug
TradeExpire = 15 * 60 // 交易过期时间,单位秒。默认 900 秒15 分钟)
SessionAccessExpire = 60 * 60 * 2 // 访问令牌过期时间,单位秒。默认 2 小时
SessionRefreshExpire = 60 * 60 * 24 * 7 // 刷新令牌过期时间,单位秒。默认 7 天
DebugHttpDump = false // 是否打印请求和响应的原始数据
UploadDir = "./data/uploads"
UploadPublicBaseURL = ""
ArticleUploadMaxBytes = 5 * 1024 * 1024
DbHost = "localhost"
DbPort = "5432"
@@ -42,6 +45,9 @@ var (
BaiyinCloudUrl string
BaiyinTokenUrl string
GostApiPort = 9700
GostApiPathPrefix = ""
IdenCallbackUrl string
IdenAccessKey string
IdenSecretKey string
@@ -106,6 +112,9 @@ func Init() {
errs = append(errs, parse(&SessionAccessExpire, "SESSION_ACCESS_EXPIRE", true, nil))
errs = append(errs, parse(&SessionRefreshExpire, "SESSION_REFRESH_EXPIRE", true, nil))
errs = append(errs, parse(&DebugHttpDump, "DEBUG_HTTP_DUMP", true, nil))
errs = append(errs, parse(&UploadDir, "UPLOAD_DIR", true, nil))
errs = append(errs, parse(&UploadPublicBaseURL, "UPLOAD_PUBLIC_BASE_URL", true, nil))
errs = append(errs, parse(&ArticleUploadMaxBytes, "ARTICLE_UPLOAD_MAX_BYTES", true, nil))
errs = append(errs, parse(&DbHost, "DB_HOST", true, nil))
errs = append(errs, parse(&DbPort, "DB_PORT", true, nil))
@@ -123,6 +132,8 @@ func Init() {
errs = append(errs, parse(&BaiyinCloudUrl, "BAIYIN_CLOUD_URL", false, nil))
errs = append(errs, parse(&BaiyinTokenUrl, "BAIYIN_TOKEN_URL", false, nil))
errs = append(errs, parse(&GostApiPort, "GOST_API_PORT", true, nil))
errs = append(errs, parse(&GostApiPathPrefix, "GOST_API_PATH_PREFIX", true, nil))
errs = append(errs, parse(&IdenCallbackUrl, "IDEN_CALLBACK_URL", false, nil))
errs = append(errs, parse(&IdenAccessKey, "IDEN_ACCESS_KEY", false, nil))

258
scripts/sql/fill-area.sql Normal file
View File

@@ -0,0 +1,258 @@
insert into area
(name, level)
values
('上海',1),
('云南',1),
('内蒙古',1),
('北京',1),
('吉林',1),
('四川',1),
('天津',1),
('宁夏',1),
('安徽',1),
('山东',1),
('山西',1),
('广东',1),
('广西',1),
('新疆',1),
('江苏',1),
('江西',1),
('河北',1),
('河南',1),
('浙江',1),
('海南',1),
('湖北',1),
('湖南',1),
('甘肃',1),
('福建',1),
('贵州',1),
('辽宁',1),
('重庆',1),
('陕西',1),
('黑龙江',1)
;
insert into area
(name, level, parent_id)
values
('上海', 2, (select id from area where name = '上海')),
('昆明', 2, (select id from area where name = '云南')),
('包头', 2, (select id from area where name = '内蒙古')),
('呼伦贝尔', 2, (select id from area where name = '内蒙古')),
('呼和浩特', 2, (select id from area where name = '内蒙古')),
('赤峰', 2, (select id from area where name = '内蒙古')),
('通辽', 2, (select id from area where name = '内蒙古')),
('鄂尔多斯', 2, (select id from area where name = '内蒙古')),
('北京', 2, (select id from area where name = '北京')),
('四平', 2, (select id from area where name = '吉林')),
('延边朝鲜族自治州', 2, (select id from area where name = '吉林')),
('松原', 2, (select id from area where name = '吉林')),
('白山', 2, (select id from area where name = '吉林')),
('通化', 2, (select id from area where name = '吉林')),
('长春', 2, (select id from area where name = '吉林')),
('乐山', 2, (select id from area where name = '四川')),
('内江', 2, (select id from area where name = '四川')),
('南充', 2, (select id from area where name = '四川')),
('宜宾', 2, (select id from area where name = '四川')),
('广元', 2, (select id from area where name = '四川')),
('德阳', 2, (select id from area where name = '四川')),
('成都', 2, (select id from area where name = '四川')),
('攀枝花', 2, (select id from area where name = '四川')),
('泸州', 2, (select id from area where name = '四川')),
('绵阳', 2, (select id from area where name = '四川')),
('自贡', 2, (select id from area where name = '四川')),
('达州', 2, (select id from area where name = '四川')),
('天津', 2, (select id from area where name = '天津')),
('银川', 2, (select id from area where name = '宁夏')),
('亳州', 2, (select id from area where name = '安徽')),
('六安', 2, (select id from area where name = '安徽')),
('合肥', 2, (select id from area where name = '安徽')),
('安庆', 2, (select id from area where name = '安徽')),
('宣城', 2, (select id from area where name = '安徽')),
('宿州', 2, (select id from area where name = '安徽')),
('池州', 2, (select id from area where name = '安徽')),
('淮北', 2, (select id from area where name = '安徽')),
('淮南', 2, (select id from area where name = '安徽')),
('滁州', 2, (select id from area where name = '安徽')),
('芜湖', 2, (select id from area where name = '安徽')),
('蚌埠', 2, (select id from area where name = '安徽')),
('铜陵', 2, (select id from area where name = '安徽')),
('阜阳', 2, (select id from area where name = '安徽')),
('马鞍山', 2, (select id from area where name = '安徽')),
('黄山', 2, (select id from area where name = '安徽')),
('东营', 2, (select id from area where name = '山东')),
('临沂', 2, (select id from area where name = '山东')),
('威海', 2, (select id from area where name = '山东')),
('德州', 2, (select id from area where name = '山东')),
('日照', 2, (select id from area where name = '山东')),
('枣庄', 2, (select id from area where name = '山东')),
('泰安', 2, (select id from area where name = '山东')),
('济南', 2, (select id from area where name = '山东')),
('济宁', 2, (select id from area where name = '山东')),
('淄博', 2, (select id from area where name = '山东')),
('滨州', 2, (select id from area where name = '山东')),
('潍坊', 2, (select id from area where name = '山东')),
('烟台', 2, (select id from area where name = '山东')),
('聊城', 2, (select id from area where name = '山东')),
('菏泽', 2, (select id from area where name = '山东')),
('青岛', 2, (select id from area where name = '山东')),
('临汾', 2, (select id from area where name = '山西')),
('吕梁', 2, (select id from area where name = '山西')),
('大同', 2, (select id from area where name = '山西')),
('太原', 2, (select id from area where name = '山西')),
('忻州', 2, (select id from area where name = '山西')),
('晋城', 2, (select id from area where name = '山西')),
('朔州', 2, (select id from area where name = '山西')),
('运城', 2, (select id from area where name = '山西')),
('长治', 2, (select id from area where name = '山西')),
('阳泉', 2, (select id from area where name = '山西')),
('东莞', 2, (select id from area where name = '广东')),
('中山', 2, (select id from area where name = '广东')),
('云浮', 2, (select id from area where name = '广东')),
('佛山', 2, (select id from area where name = '广东')),
('广州', 2, (select id from area where name = '广东')),
('惠州', 2, (select id from area where name = '广东')),
('揭阳', 2, (select id from area where name = '广东')),
('梅州', 2, (select id from area where name = '广东')),
('汕头', 2, (select id from area where name = '广东')),
('汕尾', 2, (select id from area where name = '广东')),
('江门', 2, (select id from area where name = '广东')),
('河源', 2, (select id from area where name = '广东')),
('深圳', 2, (select id from area where name = '广东')),
('清远', 2, (select id from area where name = '广东')),
('湛江', 2, (select id from area where name = '广东')),
('潮州', 2, (select id from area where name = '广东')),
('珠海', 2, (select id from area where name = '广东')),
('肇庆', 2, (select id from area where name = '广东')),
('茂名', 2, (select id from area where name = '广东')),
('阳江', 2, (select id from area where name = '广东')),
('韶关', 2, (select id from area where name = '广东')),
('北海', 2, (select id from area where name = '广西')),
('南宁', 2, (select id from area where name = '广西')),
('柳州', 2, (select id from area where name = '广西')),
('桂林', 2, (select id from area where name = '广西')),
('玉林', 2, (select id from area where name = '广西')),
('贵港', 2, (select id from area where name = '广西')),
('钦州', 2, (select id from area where name = '广西')),
('乌鲁木齐', 2, (select id from area where name = '新疆')),
('南京', 2, (select id from area where name = '江苏')),
('南通', 2, (select id from area where name = '江苏')),
('宿迁', 2, (select id from area where name = '江苏')),
('常州', 2, (select id from area where name = '江苏')),
('徐州', 2, (select id from area where name = '江苏')),
('扬州', 2, (select id from area where name = '江苏')),
('无锡', 2, (select id from area where name = '江苏')),
('泰州', 2, (select id from area where name = '江苏')),
('淮安', 2, (select id from area where name = '江苏')),
('盐城', 2, (select id from area where name = '江苏')),
('苏州', 2, (select id from area where name = '江苏')),
('连云港', 2, (select id from area where name = '江苏')),
('镇江', 2, (select id from area where name = '江苏')),
('上饶', 2, (select id from area where name = '江西')),
('九江', 2, (select id from area where name = '江西')),
('南昌', 2, (select id from area where name = '江西')),
('吉安', 2, (select id from area where name = '江西')),
('宜春', 2, (select id from area where name = '江西')),
('抚州', 2, (select id from area where name = '江西')),
('新余', 2, (select id from area where name = '江西')),
('景德镇', 2, (select id from area where name = '江西')),
('萍乡', 2, (select id from area where name = '江西')),
('赣州', 2, (select id from area where name = '江西')),
('鹰潭', 2, (select id from area where name = '江西')),
('保定', 2, (select id from area where name = '河北')),
('唐山', 2, (select id from area where name = '河北')),
('廊坊', 2, (select id from area where name = '河北')),
('张家口', 2, (select id from area where name = '河北')),
('承德', 2, (select id from area where name = '河北')),
('沧州', 2, (select id from area where name = '河北')),
('石家庄', 2, (select id from area where name = '河北')),
('秦皇岛', 2, (select id from area where name = '河北')),
('衡水', 2, (select id from area where name = '河北')),
('邢台', 2, (select id from area where name = '河北')),
('邯郸', 2, (select id from area where name = '河北')),
('信阳', 2, (select id from area where name = '河南')),
('南阳', 2, (select id from area where name = '河南')),
('周口', 2, (select id from area where name = '河南')),
('商丘', 2, (select id from area where name = '河南')),
('安阳', 2, (select id from area where name = '河南')),
('开封', 2, (select id from area where name = '河南')),
('新乡', 2, (select id from area where name = '河南')),
('洛阳', 2, (select id from area where name = '河南')),
('漯河', 2, (select id from area where name = '河南')),
('焦作', 2, (select id from area where name = '河南')),
('许昌', 2, (select id from area where name = '河南')),
('郑州', 2, (select id from area where name = '河南')),
('驻马店', 2, (select id from area where name = '河南')),
('鹤壁', 2, (select id from area where name = '河南')),
('丽水', 2, (select id from area where name = '浙江')),
('台州', 2, (select id from area where name = '浙江')),
('嘉兴', 2, (select id from area where name = '浙江')),
('宁波', 2, (select id from area where name = '浙江')),
('杭州', 2, (select id from area where name = '浙江')),
('温州', 2, (select id from area where name = '浙江')),
('湖州', 2, (select id from area where name = '浙江')),
('绍兴', 2, (select id from area where name = '浙江')),
('舟山', 2, (select id from area where name = '浙江')),
('衢州', 2, (select id from area where name = '浙江')),
('金华', 2, (select id from area where name = '浙江')),
('三亚', 2, (select id from area where name = '海南')),
('文昌', 2, (select id from area where name = '海南')),
('海口', 2, (select id from area where name = '海南')),
('咸宁', 2, (select id from area where name = '湖北')),
('孝感', 2, (select id from area where name = '湖北')),
('宜昌', 2, (select id from area where name = '湖北')),
('武汉', 2, (select id from area where name = '湖北')),
('荆州', 2, (select id from area where name = '湖北')),
('荆门', 2, (select id from area where name = '湖北')),
('襄阳', 2, (select id from area where name = '湖北')),
('黄冈', 2, (select id from area where name = '湖北')),
('黄石', 2, (select id from area where name = '湖北')),
('岳阳', 2, (select id from area where name = '湖南')),
('株洲', 2, (select id from area where name = '湖南')),
('湘潭', 2, (select id from area where name = '湖南')),
('衡阳', 2, (select id from area where name = '湖南')),
('邵阳', 2, (select id from area where name = '湖南')),
('郴州', 2, (select id from area where name = '湖南')),
('长沙', 2, (select id from area where name = '湖南')),
('兰州', 2, (select id from area where name = '甘肃')),
('三明', 2, (select id from area where name = '福建')),
('南平', 2, (select id from area where name = '福建')),
('厦门', 2, (select id from area where name = '福建')),
('宁德', 2, (select id from area where name = '福建')),
('泉州', 2, (select id from area where name = '福建')),
('福州', 2, (select id from area where name = '福建')),
('莆田', 2, (select id from area where name = '福建')),
('龙岩', 2, (select id from area where name = '福建')),
('六盘水', 2, (select id from area where name = '贵州')),
('贵阳', 2, (select id from area where name = '贵州')),
('遵义', 2, (select id from area where name = '贵州')),
('铜仁', 2, (select id from area where name = '贵州')),
('黔东南苗族侗族自治州', 2, (select id from area where name = '贵州')),
('大连', 2, (select id from area where name = '辽宁')),
('抚顺', 2, (select id from area where name = '辽宁')),
('朝阳', 2, (select id from area where name = '辽宁')),
('沈阳', 2, (select id from area where name = '辽宁')),
('盘锦', 2, (select id from area where name = '辽宁')),
('营口', 2, (select id from area where name = '辽宁')),
('葫芦岛', 2, (select id from area where name = '辽宁')),
('铁岭', 2, (select id from area where name = '辽宁')),
('鞍山', 2, (select id from area where name = '辽宁')),
('重庆', 2, (select id from area where name = '重庆')),
('咸阳', 2, (select id from area where name = '陕西')),
('宝鸡', 2, (select id from area where name = '陕西')),
('渭南', 2, (select id from area where name = '陕西')),
('西安', 2, (select id from area where name = '陕西')),
('铜川', 2, (select id from area where name = '陕西')),
('七台河', 2, (select id from area where name = '黑龙江')),
('伊春', 2, (select id from area where name = '黑龙江')),
('佳木斯', 2, (select id from area where name = '黑龙江')),
('双鸭山', 2, (select id from area where name = '黑龙江')),
('哈尔滨', 2, (select id from area where name = '黑龙江')),
('大庆', 2, (select id from area where name = '黑龙江')),
('牡丹江', 2, (select id from area where name = '黑龙江')),
('绥化', 2, (select id from area where name = '黑龙江')),
('鸡西', 2, (select id from area where name = '黑龙江')),
('鹤岗', 2, (select id from area where name = '黑龙江')),
('黑河', 2, (select id from area where name = '黑龙江')),
('齐齐哈尔', 2, (select id from area where name = '黑龙江'))

6601
scripts/sql/fill-edge.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -128,7 +128,9 @@ insert into permission (name, description, sort) values
('bill', '账单', 13),
('balance_activity', '余额变动', 14),
('proxy', '代理', 15),
('coupon_user', '已发放优惠券', 16);
('coupon_user', '已发放优惠券', 16),
('article', '文档', 17),
('article_group', '文档分组', 18);
-- --------------------------
-- level 2
@@ -215,6 +217,16 @@ insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'coupon_user' and deleted_at is null), 'coupon_user:read', '读取已发放优惠券列表', 1),
((select id from permission where name = 'coupon_user' and deleted_at is null), 'coupon_user:write', '编辑已发放优惠券', 2);
-- article 子权限
insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'article' and deleted_at is null), 'article:read', '读取文档列表', 1),
((select id from permission where name = 'article' and deleted_at is null), 'article:write', '编辑文档', 2);
-- article_group 子权限
insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'article_group' and deleted_at is null), 'article_group:read', '读取文档分组列表', 1),
((select id from permission where name = 'article_group' and deleted_at is null), 'article_group:write', '编辑文档分组', 2);
-- --------------------------
-- level 3
-- --------------------------
@@ -227,6 +239,10 @@ insert into permission (parent_id, name, description, sort) values
insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'proxy:write' and deleted_at is null), 'proxy:write:status', '更改代理状态', 1);
-- channel:write 子权限
insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'channel:write' and deleted_at is null), 'channel:write:clear_expired', '清理过期 IP', 1);
-- resource:short 子权限
insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'resource:short' and deleted_at is null), 'resource:short:read', '读取用户短效动态套餐列表', 1);
@@ -273,6 +289,10 @@ insert into permission (parent_id, name, description, sort) values
insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'coupon_user:read' and deleted_at is null), 'coupon_user:read:of_user', '读取指定用户的已发放优惠券列表', 1);
-- trade:write 子权限
insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'trade:write' and deleted_at is null), 'trade:write:complete', '完成交易', 1);
-- --------------------------
-- level 4
-- --------------------------

View File

@@ -143,6 +143,62 @@ comment on column announcement.created_at is '创建时间';
comment on column announcement.updated_at is '更新时间';
comment on column announcement.deleted_at is '删除时间';
-- article_group
drop table if exists article_group cascade;
create table article_group (
id int generated by default as identity primary key,
name text not null,
code text not null,
sort int not null default 0,
status int not null default 1,
created_at timestamptz default current_timestamp,
updated_at timestamptz default current_timestamp,
deleted_at timestamptz
);
create unique index udx_article_group_code on article_group (code) where deleted_at is null;
create index idx_article_group_status on article_group (status) where deleted_at is null;
create index idx_article_group_sort on article_group (sort) where deleted_at is null;
-- article_group表字段注释
comment on table article_group is '文章分组表';
comment on column article_group.id is '分组ID';
comment on column article_group.name is '分组名称';
comment on column article_group.code is '分组编码';
comment on column article_group.sort is '分组排序';
comment on column article_group.status is '分组状态0-禁用1-正常';
comment on column article_group.created_at is '创建时间';
comment on column article_group.updated_at is '更新时间';
comment on column article_group.deleted_at is '删除时间';
-- article
drop table if exists article cascade;
create table article (
id int generated by default as identity primary key,
group_id int not null,
title text not null,
content text,
sort int not null default 0,
status int not null default 1,
created_at timestamptz default current_timestamp,
updated_at timestamptz default current_timestamp,
deleted_at timestamptz
);
create index idx_article_group_id on article (group_id) where deleted_at is null;
create index idx_article_status on article (status) where deleted_at is null;
create index idx_article_sort on article (sort) where deleted_at is null;
-- article表字段注释
comment on table article is '文章表';
comment on column article.id is '文章ID';
comment on column article.group_id is '分组ID';
comment on column article.title is '文章标题';
comment on column article.content is '文章内容';
comment on column article.sort is '文章排序';
comment on column article.status is '文章状态0-禁用1-正常';
comment on column article.created_at is '创建时间';
comment on column article.updated_at is '更新时间';
comment on column article.deleted_at is '删除时间';
-- inquiry
drop table if exists inquiry cascade;
create table inquiry (
@@ -549,6 +605,7 @@ create table proxy (
mac text not null,
ip inet not null,
host text,
port int,
secret text,
type int not null,
status int not null,
@@ -565,17 +622,43 @@ create index idx_proxy_created_at on proxy (created_at) where deleted_at is null
comment on table proxy is '代理服务表';
comment on column proxy.id is '代理服务ID';
comment on column proxy.version is '代理服务版本';
comment on column proxy.port is '代理服务端口';
comment on column proxy.mac is '代理服务名称';
comment on column proxy.ip is '代理服务地址';
comment on column proxy.host is '代理服务域名';
comment on column proxy.secret is '代理服务密钥';
comment on column proxy.type is '代理服务类型1-自有2-白银';
comment on column proxy.type is '代理服务类型1-自有2-白银3-GOST';
comment on column proxy.status is '代理服务状态0-离线1-在线';
comment on column proxy.meta is '代理服务元信息';
comment on column proxy.created_at is '创建时间';
comment on column proxy.updated_at is '更新时间';
comment on column proxy.deleted_at is '删除时间';
-- area
drop table if exists area cascade;
create table area (
id int generated by default as identity primary key,
name text not null,
level int not null,
parent_id int,
created_at timestamptz default current_timestamp,
updated_at timestamptz default current_timestamp,
deleted_at timestamptz
);
create index idx_area_level on area (level) where deleted_at is null;
create index idx_area_parent_id on area (parent_id) where deleted_at is null;
create index idx_area_created_at on area (created_at) where deleted_at is null;
-- area表字段注释
comment on table area is '地区表';
comment on column area.id is '地区ID';
comment on column area.name is '地区名称';
comment on column area.level is '地区层级1-省2-市';
comment on column area.parent_id is '父级地区ID';
comment on column area.created_at is '创建时间';
comment on column area.updated_at is '更新时间';
comment on column area.deleted_at is '删除时间';
-- edge
drop table if exists edge cascade;
create table edge (
@@ -584,9 +667,9 @@ create table edge (
version int not null,
mac text not null,
ip inet not null,
port int,
isp int not null,
prov text not null,
city text not null,
area_id int,
status int not null default 0,
rtt int default 0,
loss int default 0,
@@ -596,20 +679,19 @@ create table edge (
);
create unique index udx_edge_mac on edge (mac) where deleted_at is null;
create index idx_edge_isp on edge (isp) where deleted_at is null;
create index idx_edge_prov on edge (prov) where deleted_at is null;
create index idx_edge_city on edge (city) where deleted_at is null;
create index idx_edge_area_id on edge (area_id) where deleted_at is null;
create index idx_edge_created_at on edge (created_at) where deleted_at is null;
-- edge表字段注释
comment on table edge is '节点表';
comment on column edge.id is '节点ID';
comment on column edge.type is '节点类型1-自建';
comment on column edge.type is '节点类型1-自建2-GOST chain';
comment on column edge.version is '节点版本';
comment on column edge.mac is '节点 mac 地址';
comment on column edge.ip is '节点地址';
comment on column edge.mac is '节点 mac 地址或 GOST chain 名称';
comment on column edge.ip is '节点地址或 GOST chain addr 的 IP';
comment on column edge.port is 'GOST chain addr 的端口';
comment on column edge.isp is '运营商1-电信2-联通3-移动';
comment on column edge.prov is '省份';
comment on column edge.city is '城市';
comment on column edge.area_id is '城市地区ID';
comment on column edge.status is '节点状态0-离线1-正常';
comment on column edge.rtt is '最近平均延迟';
comment on column edge.loss is '最近丢包率';
@@ -1177,6 +1259,10 @@ alter table channel
add constraint fk_channel_user_id foreign key (user_id) references "user" (id) on delete cascade;
alter table channel
add constraint fk_channel_proxy_id foreign key (proxy_id) references proxy (id) on delete set null;
alter table area
add constraint fk_area_parent_id foreign key (parent_id) references area (id) on delete set null;
alter table edge
add constraint fk_edge_area_id foreign key (area_id) references area (id) on delete restrict;
alter table channel
add constraint fk_channel_edge_id foreign key (edge_id) references edge (id) on delete set null;
alter table channel
@@ -1228,6 +1314,10 @@ alter table coupon_user
alter table coupon_user
add constraint fk_coupon_user_coupon_id foreign key (coupon_id) references coupon (id) on delete cascade;
-- article表外键
alter table article
add constraint fk_article_group_id foreign key (group_id) references article_group (id) on delete restrict;
-- product_sku表外键
alter table product_sku
add constraint fk_product_sku_product_id foreign key (product_id) references product (id) on delete cascade;

View File

@@ -74,6 +74,14 @@ const (
ScopeProxyWrite = string("proxy:write") // 写入代理
ScopeProxyWriteStatus = string("proxy:write:status") // 更改代理状态
ScopeArticle = string("article") // 文档
ScopeArticleRead = string("article:read") // 读取文档列表
ScopeArticleWrite = string("article:write") // 写入文档
ScopeArticleGroup = string("article_group") // 文档分组
ScopeArticleGroupRead = string("article_group:read") // 读取文档分组列表
ScopeArticleGroupWrite = string("article_group:write") // 写入文档分组
ScopeTrade = string("trade") // 交易
ScopeTradeRead = string("trade:read") // 读取交易列表
ScopeTradeReadOfUser = string("trade:read:of_user") // 读取指定用户的交易列表

View File

@@ -52,7 +52,8 @@ func ErrorHandler(c *fiber.Ctx, err error) error {
case errors.As(err, &servErr):
code = fiber.StatusInternalServerError
message = err.Error()
slog.Warn("服务端错误", slog.String("error", servErr.Error()))
message = "服务端错误"
case errors.As(err, &timeErr):
code = fiber.StatusBadRequest

280
web/globals/gost.go Normal file
View File

@@ -0,0 +1,280 @@
package globals
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"platform/web/core"
"strings"
)
var ErrGostNotFound = errors.New("gost resource not found")
func IsGostNotFound(err error) bool {
return errors.Is(err, ErrGostNotFound)
}
type GostClient interface {
ListChains() ([]*GostChainConfig, error)
GetChain(name string) (*GostChainConfig, error)
CreateChain(chain *GostChainConfig) error
DeleteChain(name string) error
SaveConfig() error
CreateService(service *GostServiceConfig) error
DeleteService(name string) error
CreateAuther(auther *GostAutherConfig) error
DeleteAuther(name string) error
CreateAdmission(admission *GostAdmissionConfig) error
DeleteAdmission(name string) error
}
type gostClient struct {
baseURL string
pathPrefix string
username string
password string
}
var GostInitializer = func(host string, port int, pathPrefix, username, password string) GostClient {
baseURL := strings.TrimSpace(host)
if !strings.Contains(baseURL, "://") {
baseURL = fmt.Sprintf("http://%s:%d", baseURL, port)
}
return &gostClient{
baseURL: strings.TrimRight(baseURL, "/"),
pathPrefix: normalizeGostPathPrefix(pathPrefix),
username: username,
password: password,
}
}
func NewGost(host string, port int, pathPrefix, username, password string) GostClient {
return GostInitializer(host, port, pathPrefix, username, password)
}
type GostChainConfig struct {
Name string `json:"name"`
Hops []GostHopConfig `json:"hops,omitempty"`
}
type GostHopConfig struct {
Name string `json:"name,omitempty"`
Nodes []GostNodeConfig `json:"nodes,omitempty"`
}
type GostNodeConfig struct {
Name string `json:"name,omitempty"`
Addr string `json:"addr"`
Connector GostConnectorConfig `json:"connector"`
Dialer GostDialerConfig `json:"dialer"`
}
type GostConnectorConfig struct {
Type string `json:"type"`
}
type GostDialerConfig struct {
Type string `json:"type"`
}
type GostServiceConfig struct {
Name string `json:"name"`
Addr string `json:"addr"`
Admission string `json:"admission,omitempty"`
Handler GostHandlerConfig `json:"handler"`
Listener GostListenerConfig `json:"listener"`
Recorders []GostRecorderConfig `json:"recorders,omitempty"`
}
type GostHandlerConfig struct {
Type string `json:"type"`
Chain string `json:"chain,omitempty"`
Auther string `json:"auther,omitempty"`
}
type GostListenerConfig struct {
Type string `json:"type"`
}
type GostRecorderConfig struct {
Name string `json:"name"`
Record string `json:"record"`
}
type GostAutherConfig struct {
Name string `json:"name"`
Auths []GostAuthConfig `json:"auths"`
}
type GostAuthConfig struct {
Username string `json:"username"`
Password string `json:"password"`
}
type GostAdmissionConfig struct {
Name string `json:"name"`
Whitelist bool `json:"whitelist"`
Matchers []string `json:"matchers"`
}
func (c *gostClient) GetChain(name string) (*GostChainConfig, error) {
body, err := c.get("/config/chains/" + url.PathEscape(name))
if err != nil {
return nil, err
}
if len(body) == 0 {
return &GostChainConfig{Name: name}, nil
}
var direct GostChainConfig
if err := json.Unmarshal(body, &direct); err == nil && direct.Name != "" {
return &direct, nil
}
var wrapper struct {
Data *GostChainConfig `json:"data"`
}
if err := json.Unmarshal(body, &wrapper); err == nil && wrapper.Data != nil && wrapper.Data.Name != "" {
return wrapper.Data, nil
}
return &GostChainConfig{Name: name}, nil
}
func (c *gostClient) ListChains() ([]*GostChainConfig, error) {
body, err := c.get("/config/chains")
if err != nil {
return nil, err
}
if len(body) == 0 {
return nil, nil
}
var resp struct {
Data struct {
Count int `json:"count"`
List []*GostChainConfig `json:"list"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("parse gost chain list failed %s: %w", string(body), err)
}
return resp.Data.List, nil
}
func (c *gostClient) CreateChain(chain *GostChainConfig) error {
return c.create("/config/chains", chain)
}
func (c *gostClient) DeleteChain(name string) error {
return c.delete("/config/chains/" + url.PathEscape(name))
}
func (c *gostClient) SaveConfig() error {
return c.create("/config", nil)
}
func (c *gostClient) CreateService(service *GostServiceConfig) error {
return c.create("/config/services", service)
}
func (c *gostClient) DeleteService(name string) error {
return c.delete("/config/services/" + url.PathEscape(name))
}
func (c *gostClient) CreateAuther(auther *GostAutherConfig) error {
return c.create("/config/authers", auther)
}
func (c *gostClient) DeleteAuther(name string) error {
return c.delete("/config/authers/" + url.PathEscape(name))
}
func (c *gostClient) CreateAdmission(admission *GostAdmissionConfig) error {
return c.create("/config/admissions", admission)
}
func (c *gostClient) DeleteAdmission(name string) error {
return c.delete("/config/admissions/" + url.PathEscape(name))
}
func (c *gostClient) create(path string, payload any) error {
_, err := c.request(http.MethodPost, path, payload)
return err
}
func (c *gostClient) get(path string) ([]byte, error) {
body, err := c.request(http.MethodGet, path, nil)
if err != nil {
return nil, err
}
return body, nil
}
func (c *gostClient) delete(path string) error {
_, err := c.request(http.MethodDelete, path, nil)
return err
}
func (c *gostClient) request(method string, path string, payload any) ([]byte, error) {
var bodyReader io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, c.endpoint(path), bodyReader)
if err != nil {
return nil, err
}
req.SetBasicAuth(c.username, c.password)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := core.Fetch(req)
if err != nil {
return nil, err
}
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusBadRequest {
return nil, fmt.Errorf("%w: %s", ErrGostNotFound, string(body))
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("gost api %s %s failed: %d %s", method, path, resp.StatusCode, string(body))
}
return body, nil
}
func (c *gostClient) endpoint(path string) string {
return c.baseURL + c.pathPrefix + path
}
func normalizeGostPathPrefix(prefix string) string {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return ""
}
if !strings.HasPrefix(prefix, "/") {
prefix = "/" + prefix
}
return strings.TrimRight(prefix, "/")
}

107
web/globals/gost_test.go Normal file
View File

@@ -0,0 +1,107 @@
package globals
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestGostClientChainOperations(t *testing.T) {
var (
created *GostChainConfig
deleted []string
saved bool
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok || username != "user" || password != "pass" {
t.Errorf("unexpected auth: ok=%v username=%q password=%q", ok, username, password)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/config/chains":
_ = json.NewEncoder(w).Encode(map[string]any{
"count": 2,
"list": []map[string]any{
{"name": "old-a"},
{"name": "old-b"},
},
})
case r.Method == http.MethodPost && r.URL.Path == "/api/config/chains":
if err := json.NewDecoder(r.Body).Decode(&created); err != nil {
t.Errorf("Decode chain failed: %v", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
_, _ = w.Write([]byte(`{}`))
case r.Method == http.MethodDelete && r.URL.Path == "/api/config/chains/old-a":
deleted = append(deleted, "old-a")
_, _ = w.Write([]byte(`{}`))
case r.Method == http.MethodDelete && r.URL.Path == "/api/config/chains/old-b":
deleted = append(deleted, "old-b")
_, _ = w.Write([]byte(`{}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/config":
saved = true
_, _ = w.Write([]byte(`{}`))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
defer server.Close()
client := NewGost(server.URL, 9700, "/api", "user", "pass")
chains, err := client.ListChains()
if err != nil {
t.Fatalf("ListChains returned error: %v", err)
}
if len(chains) != 2 || chains[0].Name != "old-a" || chains[1].Name != "old-b" {
t.Fatalf("unexpected chains: %#v", chains)
}
if err := client.DeleteChain(chains[0].Name); err != nil {
t.Fatalf("DeleteChain old-a returned error: %v", err)
}
if err := client.DeleteChain(chains[1].Name); err != nil {
t.Fatalf("DeleteChain old-b returned error: %v", err)
}
if len(deleted) != 2 {
t.Fatalf("unexpected deleted chains: %#v", deleted)
}
err = client.CreateChain(&GostChainConfig{
Name: "edge-a",
Hops: []GostHopConfig{{
Nodes: []GostNodeConfig{{
Addr: "192.0.2.1:1080",
Connector: GostConnectorConfig{Type: "socks5"},
Dialer: GostDialerConfig{Type: "tcp"},
}},
}},
})
if err != nil {
t.Fatalf("CreateChain returned error: %v", err)
}
if created == nil || created.Name != "edge-a" {
t.Fatalf("unexpected created chain: %#v", created)
}
if len(created.Hops) != 1 || len(created.Hops[0].Nodes) != 1 {
t.Fatalf("unexpected created chain hops: %#v", created.Hops)
}
node := created.Hops[0].Nodes[0]
if node.Addr != "192.0.2.1:1080" || node.Connector.Type != "socks5" || node.Dialer.Type != "tcp" {
t.Fatalf("unexpected created node: %#v", node)
}
if err := client.SaveConfig(); err != nil {
t.Fatalf("SaveConfig returned error: %v", err)
}
if !saved {
t.Fatal("expected SaveConfig request")
}
}

29
web/handlers/area.go Normal file
View File

@@ -0,0 +1,29 @@
package handlers
import (
"platform/web/auth"
s "platform/web/services"
"github.com/gofiber/fiber/v2"
)
func ListArea(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitOfficialClient()
if err != nil {
return err
}
list, err := s.Area.ListAreas()
if err != nil {
return err
}
return c.JSON(list)
}
type ListAreaRespItem struct {
ID int32 `json:"id"`
Name string `json:"name"`
Level int `json:"level"`
ParentID *int32 `json:"parent_id,omitempty"`
}

170
web/handlers/article.go Normal file
View File

@@ -0,0 +1,170 @@
package handlers
import (
"platform/pkg/env"
"platform/web/auth"
"platform/web/core"
g "platform/web/globals"
s "platform/web/services"
"strings"
"github.com/gofiber/fiber/v2"
)
func NavArticle(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitOfficialClient()
if err != nil {
return err
}
list, err := s.Article.Nav()
if err != nil {
return err
}
return c.JSON(list)
}
func GetArticle(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitOfficialClient()
if err != nil {
return err
}
var req core.IdReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
article, err := s.Article.GetPublic(req.Id)
if err != nil {
return err
}
return c.JSON(article)
}
func PageArticleByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeArticleRead)
if err != nil {
return err
}
var req s.PageArticleReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
list, total, err := s.Article.Page(&req)
if err != nil {
return err
}
return c.JSON(core.PageResp{
Total: int(total),
Page: req.GetPage(),
Size: req.GetSize(),
List: list,
})
}
func GetArticleByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeArticleRead)
if err != nil {
return err
}
var req core.IdReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
article, err := s.Article.GetByAdmin(req.Id)
if err != nil {
return err
}
return c.JSON(article)
}
func CreateArticle(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeArticleWrite)
if err != nil {
return err
}
var req s.CreateArticleData
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
return s.Article.Create(req)
}
func UpdateArticle(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeArticleWrite)
if err != nil {
return err
}
var req s.UpdateArticleData
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
return s.Article.Update(req)
}
func DeleteArticle(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeArticleWrite)
if err != nil {
return err
}
var req core.IdReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
return s.Article.Delete(req.Id)
}
func UploadArticleImage(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeArticleWrite)
if err != nil {
return err
}
fileHeader, err := c.FormFile("file")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "缺少上传文件 file")
}
result, err := s.Article.UploadImage(fileHeader, articleUploadBaseURL(c))
if err != nil {
return err
}
return c.JSON(result)
}
func articleUploadBaseURL(c *fiber.Ctx) string {
if env.UploadPublicBaseURL != "" {
return strings.TrimRight(env.UploadPublicBaseURL, "/")
}
scheme := c.Protocol()
if forwardedProto := c.Get("X-Forwarded-Proto"); forwardedProto != "" {
scheme = strings.TrimSpace(strings.Split(forwardedProto, ",")[0])
}
host := c.Get(fiber.HeaderHost)
if forwardedHost := c.Get("X-Forwarded-Host"); forwardedHost != "" {
host = strings.TrimSpace(strings.Split(forwardedHost, ",")[0])
}
if host == "" {
return ""
}
return scheme + "://" + host
}

View File

@@ -0,0 +1,90 @@
package handlers
import (
"platform/web/auth"
"platform/web/core"
g "platform/web/globals"
s "platform/web/services"
"github.com/gofiber/fiber/v2"
)
func PageArticleGroupByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeArticleGroupRead)
if err != nil {
return err
}
var req s.PageArticleGroupReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
list, total, err := s.ArticleGroup.Page(&req)
if err != nil {
return err
}
return c.JSON(core.PageResp{
Total: int(total),
Page: req.GetPage(),
Size: req.GetSize(),
List: list,
})
}
func ListArticleGroupByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeArticleGroupRead)
if err != nil {
return err
}
list, err := s.ArticleGroup.All()
if err != nil {
return err
}
return c.JSON(list)
}
func CreateArticleGroup(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeArticleGroupWrite)
if err != nil {
return err
}
var req s.CreateArticleGroupData
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
return s.ArticleGroup.Create(req)
}
func UpdateArticleGroup(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeArticleGroupWrite)
if err != nil {
return err
}
var req s.UpdateArticleGroupData
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
return s.ArticleGroup.Update(req)
}
func DeleteArticleGroup(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeArticleGroupWrite)
if err != nil {
return err
}
var req core.IdReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
return s.ArticleGroup.Delete(req.Id)
}

View File

@@ -106,41 +106,28 @@ func CreateChannel(c *fiber.Ctx) error {
if err != nil {
return err
}
var isp *m.EdgeISP
areaID, err := s.Area.FindIdByFilter(req.Prov, req.City)
if err != nil {
return err
}
filter := &s.EdgeFilter{AreaID: areaID}
if req.Isp != nil {
isp = u.X(m.ToEdgeISP(*req.Isp))
filter.Isp = u.X(m.ToEdgeISP(*req.Isp))
}
result, err := s.Channel.CreateChannels(
ip, no,
ip,
no,
req.AuthType == s.ChannelAuthTypeIp,
req.AuthType == s.ChannelAuthTypePass,
req.Count,
&s.EdgeFilter{
Isp: isp,
Prov: req.Prov,
City: req.City,
},
filter,
)
if err != nil {
return err
}
// 返回结果
var resp = make([]*CreateChannelRespItem, len(result))
for i, channel := range result {
resp[i] = &CreateChannelRespItem{
Proto: req.Protocol,
Host: channel.Host,
IP: channel.Proxy.IP.String(),
Port: channel.Port,
}
if req.AuthType == s.ChannelAuthTypePass {
resp[i].Username = channel.Username
resp[i].Password = channel.Password
}
}
return c.JSON(resp)
return c.JSON(buildCreateChannelResp(result, req.Protocol, req.AuthType))
}
type CreateChannelReq struct {
@@ -169,9 +156,13 @@ func CreateChannelV2(c *fiber.Ctx) error {
}
// 创建通道
var isp *m.EdgeISP
areaID, err := s.Area.FindIdByFilter(req.Prov, req.City)
if err != nil {
return err
}
filter := &s.EdgeFilter{AreaID: areaID}
if req.Isp != nil {
isp = u.X(m.ToEdgeISP(*req.Isp))
filter.Isp = u.X(m.ToEdgeISP(*req.Isp))
}
result, err := s.Channel.CreateChannels(
ip,
@@ -179,31 +170,14 @@ func CreateChannelV2(c *fiber.Ctx) error {
req.AuthType == s.ChannelAuthTypeIp,
req.AuthType == s.ChannelAuthTypePass,
req.Count,
&s.EdgeFilter{
Isp: isp,
Prov: req.Prov,
City: req.City,
},
filter,
)
if err != nil {
return err
}
// 返回结果
var resp = make([]*CreateChannelRespItem, len(result))
for i, channel := range result {
resp[i] = &CreateChannelRespItem{
Proto: req.Protocol,
Host: channel.Host,
IP: channel.Proxy.IP.String(),
Port: channel.Port,
}
if req.AuthType == s.ChannelAuthTypePass {
resp[i].Username = channel.Username
resp[i].Password = channel.Password
}
}
return c.JSON(resp)
return c.JSON(buildCreateChannelResp(result, req.Protocol, req.AuthType))
}
type CreateChannelReqV2 struct {
@@ -216,6 +190,46 @@ type CreateChannelReqV2 struct {
Isp *int `json:"isp"`
}
// CreateChannelV3 创建新通道 v3使用 resource_no + area_id
func CreateChannelV3(c *fiber.Ctx) error {
req := new(CreateChannelReqV3)
if err := g.Validator.ParseBody(c, req); err != nil {
return core.NewBizErr("解析参数失败", err)
}
ip, err := netip.ParseAddr(c.IP())
if err != nil {
return core.NewBizErr("获取客户端地址失败", err)
}
filter := &s.EdgeFilter{AreaID: req.AreaID}
if req.Isp != nil {
filter.Isp = u.X(m.ToEdgeISP(*req.Isp))
}
result, err := s.Channel.CreateChannels(
ip,
req.ResourceNo,
req.AuthType == s.ChannelAuthTypeIp,
req.AuthType == s.ChannelAuthTypePass,
req.Count,
filter,
)
if err != nil {
return err
}
return c.JSON(buildCreateChannelResp(result, req.Protocol, req.AuthType))
}
type CreateChannelReqV3 struct {
ResourceNo string `json:"resource_no" validate:"required"`
AuthType s.ChannelAuthType `json:"auth_type" validate:"required"`
Protocol int `json:"protocol" validate:"required"`
Count int `json:"count" validate:"required"`
AreaID *int32 `json:"area_id"`
Isp *int `json:"isp"`
}
type CreateChannelRespItem struct {
Proto int `json:"-"`
Host string `json:"host"`
@@ -443,3 +457,20 @@ type SyncChannelClearExpiredByAdminReq struct {
type SyncChannelClearExpiredByAdminResp struct {
Count int `json:"count"`
}
func buildCreateChannelResp(result []*m.Channel, protocol int, authType s.ChannelAuthType) []*CreateChannelRespItem {
resp := make([]*CreateChannelRespItem, len(result))
for i, channel := range result {
resp[i] = &CreateChannelRespItem{
Proto: protocol,
Host: channel.Host,
IP: channel.Proxy.IP.String(),
Port: channel.Port,
}
if authType == s.ChannelAuthTypePass {
resp[i].Username = channel.Username
resp[i].Password = channel.Password
}
}
return resp
}

View File

@@ -1,149 +0,0 @@
package handlers
import (
s "platform/web/services"
"github.com/gofiber/fiber/v2"
)
type RegisterEdgeReq struct {
Name string `json:"name" validate:"required"`
Version int `json:"version" validate:"required"`
}
type RegisterEdgeResp struct {
Id int32 `json:"id"`
Host string `json:"host"`
}
func AssignEdge(c *fiber.Ctx) (err error) {
return c.JSON(map[string]any{
"error": "接口暂不可用",
})
// // 验证请求参数
// var req = new(RegisterEdgeReq)
// err = g.Validator.Validate(c, req)
// if err != nil {
// return err
// }
// // 全局锁,防止并发注册
// var mutex = g.Redsync.NewMutex("edge:discovery")
// if err := mutex.Lock(); err != nil {
// return errors.New("服务繁忙,请稍后重试")
// }
// defer func() {
// if ok, err := mutex.Unlock(); err != nil {
// slog.Error("解锁失败", slog.Bool("ok", ok), slog.Any("err", err))
// }
// }()
// // 检查节点
// var fwd *m.Proxy
// var edge *m.Edge
// edge, err = q.Edge.
// Where(q.Edge.Mac.Eq(req.Name)).
// Take()
// if errors.Is(err, gorm.ErrRecordNotFound) {
// // 挑选合适的转发服务
// fwd, err = q.Proxy.
// LeftJoin(q.Edge, q.Edge.ProxyID.EqCol(q.Proxy.ID), q.Edge.Status.Eq(1)).
// Select(q.Proxy.ALL, q.Edge.ALL.Count().As("count")).
// Where(q.Proxy.Type.Eq(int32(proxy2.TypeSelfHosted))).
// Group(q.Proxy.ID).
// Order(field.NewField("", "count").Desc()).
// First()
// if err != nil {
// return err
// }
// // 保存节点信息
// edge = &m.Edge{
// Name: req.Name,
// Version: int32(req.Version),
// Type: int32(edge2.TypeSelfHosted),
// ProxyID: &fwd.ID,
// }
// err = q.Edge.Create(edge)
// if err != nil {
// return err
// }
// } else if err == nil {
// // 获取已配置的转发服务
// fwd, err = q.Proxy.
// Where(q.Proxy.ID.Eq(*edge.ProxyID)).
// Take()
// if err != nil {
// return err
// }
// // 节点已存在,更新版本号
// if edge.Version < int32(req.Version) {
// _, err = q.Edge.
// Where(q.Edge.ID.Eq(edge.ID)).
// UpdateSimple(q.Edge.Version.Value(int32(req.Version)))
// if err != nil {
// return err
// }
// }
// } else {
// return err
// }
// // 返回服务地址
// return c.JSON(RegisterEdgeResp{
// Id: edge.ID,
// Host: fwd.Host,
// })
}
type AllEdgesAvailableReq struct {
s.EdgeFilter
Count int `json:"count"`
}
type AllEdgesAvailableRespItem struct {
Ip string `json:"ip"` // 节点地址
Port int32 `json:"port"` // 代理服务端口
Isp string `json:"isp"` // 运营商
Prov string `json:"prov"` // 省份
City string `json:"city"` // 城市
Status int32 `json:"status"` // 节点状态0-离线1-正常
}
func AllEdgesAvailable(c *fiber.Ctx) (err error) {
return c.JSON(map[string]any{
"error": "接口暂不可用",
})
// // 检查权限
// _, err = auth.GetAuthCtx(c).PermitSecretClient()
// if err != nil {
// return err
// }
// // 验证请求参数
// var req = new(AllEdgesAvailableReq)
// err = g.Validator.Validate(c, req)
// if err != nil {
// return err
// }
// // 获取可用的转发服务
// infos, err := s.Edge.AllEdges(req.Count, req.EdgeFilter)
// if err != nil {
// return err
// }
// // 返回结果
// var edges = make([]AllEdgesAvailableRespItem, len(infos))
// for i, info := range infos {
// edges[i] = AllEdgesAvailableRespItem{
// Ip: info.Host,
// Port: u.Z(info.ProxyPort),
// Isp: edge2.ISP(info.Isp).String(),
// Prov: info.Prov,
// City: info.City,
// Status: info.Status,
// }
// }
// return c.JSON(edges)
}

View File

@@ -5,11 +5,14 @@ import (
"platform/web/core"
g "platform/web/globals"
s "platform/web/services"
"time"
"github.com/gofiber/fiber/v2"
)
// ====================
// admin 路由
// ====================
func PageProxyByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeProxyRead)
if err != nil {
@@ -102,6 +105,42 @@ func UpdateProxyStatus(c *fiber.Ctx) error {
return c.JSON(nil)
}
func SyncProxyPorts(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeProxyWrite)
if err != nil {
return err
}
var req core.IdReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
if err := s.Proxy.SyncPorts(req.Id); err != nil {
return err
}
return c.JSON(nil)
}
func SyncProxyChains(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeProxyWrite)
if err != nil {
return err
}
var req core.IdReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
if err := s.Proxy.SyncChains(req.Id); err != nil {
return err
}
return c.JSON(nil)
}
func RemoveProxy(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeProxyWrite)
if err != nil {
@@ -119,347 +158,3 @@ func RemoveProxy(c *fiber.Ctx) error {
return c.JSON(nil)
}
// ====================
// region 报告上线
func ProxyReportOnline(c *fiber.Ctx) (err error) {
return c.JSON(map[string]any{
"error": "接口暂不可用",
})
// // 检查接口权限
// _, err = auth2.GetAuthCtx(c).PermitSecretClient()
// if err != nil {
// return err
// }
// // 验证请求参数
// var req = new(ProxyReportOnlineReq)
// err = g.Validator.Validate(c, req)
// if err != nil {
// return err
// }
// // 创建代理
// var ip = c.Context().RemoteIP()
// var secretBytes = make([]byte, 16)
// if _, err := rand.Read(secretBytes); err != nil {
// return err
// }
// var secret = base32.StdEncoding.
// WithPadding(base32.NoPadding).
// EncodeToString(secretBytes)
// slog.Debug("生成随机密钥", "ip", ip, "secret", secret)
// var proxy = &m.Proxy{
// Mac: req.Name,
// Version: int32(req.Version),
// Type: m.ProxyTypeSelfHosted,
// IP: ip,
// Secret: &secret,
// Status: 1,
// }
// err = q.Proxy.
// Clauses(clause.OnConflict{
// UpdateAll: true,
// Columns: []clause.Column{
// {Name: q.Proxy.Mac.ColumnName().String()},
// },
// }).
// Create(proxy)
// if err != nil {
// return err
// }
// // 获取边缘节点信息
// data, err := q.Edge.Where(
// q.Edge.ProxyID.Eq(proxy.ID),
// ).Find()
// if err != nil {
// return err
// }
// edges := make([]*ProxyEdge, len(data))
// for i, edge := range data {
// edges[i] = &ProxyEdge{
// Id: edge.ID,
// Port: edge.ProxyPort,
// Prov: &edge.Prov,
// City: &edge.City,
// Isp: u.P(edge2.ISP(edge.Isp).String()),
// Status: &edge.Status,
// Loss: edge.Loss,
// Rtt: edge.Rtt,
// }
// }
// // 获取许可配置
// channels, err := q.Channel.Where(
// q.Channel.ProxyID.Eq(proxy.ID),
// q.Channel.Expiration.Gt(orm.LocalDateTime(time.Now())),
// ).Find()
// if err != nil {
// return err
// }
// var permits = make([]*ProxyPermit, len(channels))
// for i, channel := range channels {
// if channel.EdgeID == nil {
// return core.NewBizErr(fmt.Sprintf("权限解析异常通道缺少边缘节点ID %d", channel.ID))
// }
// permits[i] = &ProxyPermit{
// Id: *channel.EdgeID,
// Expire: time.Time(channel.Expiration),
// Whitelists: u.P(strings.Split(u.Z(channel.Whitelists), ",")),
// Username: channel.Username,
// Password: channel.Password,
// }
// }
// slog.Debug("注册转发服务", "ip", ip, "id", proxy.ID)
// return c.JSON(&ProxyReportOnlineResp{
// Id: proxy.ID,
// Secret: secret,
// Edges: edges,
// Permits: permits,
// })
}
type ProxyReportOnlineReq struct {
Name string `json:"name" validate:"required"`
Version int `json:"version" validate:"required"`
}
type ProxyReportOnlineResp struct {
Id int32 `json:"id"`
Secret string `json:"secret"`
Permits []*ProxyPermit `json:"permits"`
Edges []*ProxyEdge `json:"edges"`
}
// region 报告下线
func ProxyReportOffline(c *fiber.Ctx) (err error) {
return c.JSON(map[string]any{
"error": "接口暂不可用",
})
// // 检查接口权限
// _, err = auth2.GetAuthCtx(c).PermitSecretClient()
// if err != nil {
// return err
// }
// // 验证请求参数
// var req = new(ProxyReportOfflineReq)
// err = g.Validator.Validate(c, req)
// if err != nil {
// return err
// }
// // 下线转发服务
// _, err = q.Proxy.
// Where(q.Proxy.ID.Eq(req.Id)).
// UpdateSimple(q.Proxy.Status.Value(0))
// if err != nil {
// return err
// }
// // 下线所有相关的边缘节点
// _, err = q.Edge.
// Where(q.Edge.ProxyID.Eq(req.Id)).
// UpdateSimple(q.Edge.Status.Value(0))
// if err != nil {
// return err
// }
// return nil
}
type ProxyReportOfflineReq struct {
Id int32 `json:"id" validate:"required"`
}
// region 报告更新
func ProxyReportUpdate(c *fiber.Ctx) (err error) {
return c.JSON(map[string]any{
"error": "接口暂不可用",
})
// // 检查接口权限
// _, err = auth2.GetAuthCtx(c).PermitSecretClient()
// if err != nil {
// return err
// }
// // 验证请求参数
// var req = new(ProxyReportUpdateReq)
// err = g.Validator.Validate(c, req)
// if err != nil {
// return err
// }
// // 更新节点信息
// var idsActive = make([]int32, 0, len(req.Edges))
// var idsInactive = make([]int32, 0, len(req.Edges))
// var idsIspUnknown = make([]int32, 0, len(req.Edges))
// var idsIspTelecom = make([]int32, 0, len(req.Edges))
// var idsIspUnicom = make([]int32, 0, len(req.Edges))
// var idsIspMobile = make([]int32, 0, len(req.Edges))
// var otherEdges = make([]*ProxyEdge, 0, len(req.Edges))
// for _, edge := range req.Edges {
// // 检查更新ISP
// if edge.Isp != nil {
// switch edge2.ISPFromStr(*edge.Isp) {
// case edge2.IspUnknown:
// idsIspUnknown = append(idsIspUnknown, edge.Id)
// case edge2.IspChinaTelecom:
// idsIspTelecom = append(idsIspTelecom, edge.Id)
// case edge2.IspChinaUnicom:
// idsIspUnicom = append(idsIspUnicom, edge.Id)
// case edge2.IspChinaMobile:
// idsIspMobile = append(idsIspMobile, edge.Id)
// }
// }
// // 检查更新状态
// if edge.Status != nil {
// if *edge.Status == 1 {
// idsActive = append(idsActive, edge.Id)
// } else {
// idsInactive = append(idsInactive, edge.Id)
// }
// }
// // 无法分类更新
// if edge.Host != nil || edge.Port != nil || edge.Prov != nil || edge.City != nil {
// otherEdges = append(otherEdges, edge)
// continue
// }
// }
// slog.Debug("更新边缘节点信息",
// "active", len(idsActive),
// "inactive", len(idsInactive),
// "isp_unknown", len(idsIspUnknown),
// "isp_telecom", len(idsIspTelecom),
// "isp_unicom", len(idsIspUnicom),
// "isp_mobile", len(idsIspMobile),
// "other_edges", len(otherEdges),
// )
// err = q.Q.Transaction(func(q *q.Query) error {
// // 更新边缘节点状态
// if len(idsActive) > 0 {
// _, err = q.Edge.Debug().
// Where(q.Edge.ID.In(idsActive...)).
// UpdateSimple(q.Edge.Status.Value(1))
// if err != nil {
// return err
// }
// }
// if len(idsInactive) > 0 {
// _, err = q.Edge.Debug().
// Where(q.Edge.ID.In(idsInactive...)).
// UpdateSimple(q.Edge.Status.Value(0))
// if err != nil {
// return err
// }
// }
// // 更新边缘节点ISP
// if len(idsIspUnknown) > 0 {
// _, err = q.Edge.Debug().
// Where(q.Edge.ID.In(idsIspUnknown...)).
// UpdateSimple(q.Edge.Isp.Value(int32(edge2.IspUnknown)))
// if err != nil {
// return err
// }
// }
// if len(idsIspTelecom) > 0 {
// _, err = q.Edge.Debug().
// Where(q.Edge.ID.In(idsIspTelecom...)).
// UpdateSimple(q.Edge.Isp.Value(int32(edge2.IspChinaTelecom)))
// if err != nil {
// return err
// }
// }
// if len(idsIspUnicom) > 0 {
// _, err = q.Edge.Debug().
// Where(q.Edge.ID.In(idsIspUnicom...)).
// UpdateSimple(q.Edge.Isp.Value(int32(edge2.IspChinaUnicom)))
// if err != nil {
// return err
// }
// }
// if len(idsIspMobile) > 0 {
// _, err = q.Edge.Debug().
// Where(q.Edge.ID.In(idsIspMobile...)).
// UpdateSimple(q.Edge.Isp.Value(int32(edge2.IspChinaMobile)))
// if err != nil {
// return err
// }
// }
// // 更新其他边缘节点信息
// for _, edge := range otherEdges {
// do := q.Edge.Debug().Where(q.Edge.ID.Eq(edge.Id))
// var assigns = make([]field.AssignExpr, 0, 5)
// if edge.Host != nil {
// assigns = append(assigns, q.Edge.Host.Value(*edge.Host))
// }
// if edge.Port != nil {
// assigns = append(assigns, q.Edge.ProxyPort.Value(*edge.Port))
// }
// if edge.Prov != nil {
// assigns = append(assigns, q.Edge.Prov.Value(*edge.Prov))
// }
// if edge.City != nil {
// assigns = append(assigns, q.Edge.City.Value(*edge.City))
// }
// // 更新边缘节点
// _, err := do.UpdateSimple(assigns...)
// if err != nil {
// return fmt.Errorf("更新边缘节点 %d 失败: %w", edge.Id, err)
// }
// }
// return nil
// })
// if err != nil {
// return err
// }
// return nil
}
type ProxyReportUpdateReq struct {
Id int32 `json:"id" validate:"required"`
Edges []*ProxyEdge `json:"edges" validate:"required"`
}
type ProxyPermit struct {
Id int32 `json:"id"`
Expire time.Time `json:"expire"`
Whitelists *[]string `json:"whitelists"`
Username *string `json:"username"`
Password *string `json:"password"`
}
type ProxyEdge struct {
Id int32 `json:"id"`
Host *string `json:"host,omitempty"` // 边缘节点地址
Port *int32 `json:"port,omitempty"` // 边缘节点代理端口
Prov *string `json:"prov,omitempty"`
City *string `json:"city,omitempty"`
Isp *string `json:"isp,omitempty"`
Status *int32 `json:"status,omitempty"`
Loss *int32 `json:"loss,omitempty"` // 丢包率
Rtt *int32 `json:"latency,omitempty"` // 延迟
}

View File

@@ -1,11 +1,14 @@
package web
import (
"net/http"
"platform/pkg/env"
"platform/web/auth"
"github.com/gofiber/contrib/otelfiber/v2"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/filesystem"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/gofiber/fiber/v2/middleware/requestid"
@@ -66,6 +69,11 @@ func ApplyMiddlewares(app *fiber.App) {
},
}))
// static uploads
app.Use("/uploads", filesystem.New(filesystem.Config{
Root: http.Dir(env.UploadDir),
}))
// authenticate
app.Use(auth.Authenticate())
}

20
web/models/area.go Normal file
View File

@@ -0,0 +1,20 @@
package models
import "platform/web/core"
// Area 地区表
type Area struct {
core.Model
Name string `json:"name" gorm:"column:name"` // 地区名称
Level AreaLevel `json:"level" gorm:"column:level"` // 地区层级1-省2-市
ParentID *int32 `json:"parent_id,omitempty" gorm:"column:parent_id"` // 父级地区ID
Parent *Area `json:"parent,omitempty" gorm:"foreignKey:ParentID"`
}
// AreaLevel 地区层级枚举
type AreaLevel int
const (
AreaLevelProvince AreaLevel = 1 // 省
AreaLevelCity AreaLevel = 2 // 市
)

23
web/models/article.go Normal file
View File

@@ -0,0 +1,23 @@
package models
import "platform/web/core"
// Article 文章表
type Article struct {
core.Model
GroupID int32 `json:"group_id" gorm:"column:group_id"` // 分组ID
Title string `json:"title" gorm:"column:title"` // 文章标题
Content *string `json:"content,omitempty" gorm:"column:content"` // 文章内容
Sort int32 `json:"sort" gorm:"column:sort"` // 文章排序
Status ArticleStatus `json:"status" gorm:"column:status"` // 文章状态0-禁用1-正常
Group *ArticleGroup `json:"group,omitempty" gorm:"foreignKey:GroupID"` // 分组
}
// ArticleStatus 文章状态
type ArticleStatus int
const (
ArticleStatusDisabled ArticleStatus = 0 // 禁用
ArticleStatusEnabled ArticleStatus = 1 // 正常
)

View File

@@ -0,0 +1,20 @@
package models
import "platform/web/core"
// ArticleGroup 文章分组表
type ArticleGroup struct {
core.Model
Name string `json:"name" gorm:"column:name"` // 分组名称
Code string `json:"code" gorm:"column:code"` // 分组编码
Sort int32 `json:"sort" gorm:"column:sort"` // 分组排序
Status ArticleGroupStatus `json:"status" gorm:"column:status"` // 分组状态0-禁用1-正常
}
// ArticleGroupStatus 分组状态
type ArticleGroupStatus int
const (
ArticleGroupStatusDisabled ArticleGroupStatus = 0 // 禁用
ArticleGroupStatusEnabled ArticleGroupStatus = 1 // 正常
)

View File

@@ -8,16 +8,17 @@ import (
// Edge 节点表
type Edge struct {
core.Model
Type EdgeType `json:"type" gorm:"column:type"` // 节点类型1-自建
Version int32 `json:"version" gorm:"column:version"` // 节点版本
Mac string `json:"mac" gorm:"column:mac"` // 节点 mac 地址
IP orm.Inet `json:"ip" gorm:"column:ip;not null"` // 节点地址
ISP EdgeISP `json:"isp" gorm:"column:isp"` // 运营商0-未知1-电信2-联通3-移动
Prov string `json:"prov" gorm:"column:prov"` // 省份
City string `json:"city" gorm:"column:city"` // 城市
Status EdgeStatus `json:"status" gorm:"column:status"` // 节点状态0-离线1-正常
RTT int32 `json:"rtt" gorm:"column:rtt"` // 最近平均延迟
Loss int32 `json:"loss" gorm:"column:loss"` // 最近丢包率
Type EdgeType `json:"type" gorm:"column:type"` // 节点类型1-自建2-GOST chain
Version int32 `json:"version" gorm:"column:version"` // 节点版本
Mac string `json:"mac" gorm:"column:mac"` // 节点 mac 地址或 GOST chain 名称
IP orm.Inet `json:"ip" gorm:"column:ip;not null"` // 节点地址或 GOST chain addr 的 IP
Port *uint16 `json:"port,omitempty" gorm:"column:port"` // GOST chain addr 的端口
ISP EdgeISP `json:"isp" gorm:"column:isp"` // 运营商0-未知1-电信2-联通3-移动
AreaID *int32 `json:"area_id,omitempty" gorm:"column:area_id"` // 城市地区ID
Status EdgeStatus `json:"status" gorm:"column:status"` // 节点状态0-离线1-正常
RTT int32 `json:"rtt" gorm:"column:rtt"` // 最近平均延迟
Loss int32 `json:"loss" gorm:"column:loss"` // 最近丢包率
Area *Area `json:"area,omitempty" gorm:"foreignKey:AreaID"` // 地区
}
// EdgeType 节点类型枚举
@@ -25,6 +26,7 @@ type EdgeType int
const (
EdgeTypeSelfBuilt EdgeType = 1 // 自建
EdgeTypeGostChain EdgeType = 2 // GOST chain
)
// EdgeStatus 节点状态枚举
@@ -39,6 +41,7 @@ const (
type EdgeISP int
const (
EdgeISPUnknown EdgeISP = 0 // 未知/任意
EdgeISPTelecom EdgeISP = 1 // 电信
EdgeISPUnicom EdgeISP = 2 // 联通
EdgeISPMobile EdgeISP = 3 // 移动

View File

@@ -14,6 +14,7 @@ type Proxy struct {
Mac string `json:"mac" gorm:"column:mac"` // 代理服务名称
IP orm.Inet `json:"ip" gorm:"column:ip;not null"` // 代理服务地址
Host *string `json:"host,omitempty" gorm:"column:host"` // 代理服务域名
Port *int `json:"port,omitempty" gorm:"column:port"` // 代理服务端口
Secret *string `json:"secret,omitempty" gorm:"column:secret"` // 代理服务密钥
Type ProxyType `json:"type" gorm:"column:type"` // 代理服务类型1-自有2-白银
Status ProxyStatus `json:"status" gorm:"column:status"` // 代理服务状态0-离线1-在线
@@ -28,6 +29,7 @@ type ProxyType int
const (
ProxyTypeSelfHosted ProxyType = 1 // 自有
ProxyTypeBaiYin ProxyType = 2 // 白银
ProxyTypeGost ProxyType = 3 // GOST
)
// ProxyStatus 代理服务状态枚举

443
web/queries/area.gen.go Normal file
View File

@@ -0,0 +1,443 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package queries
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
"gorm.io/gen"
"gorm.io/gen/field"
"gorm.io/plugin/dbresolver"
"platform/web/models"
)
func newArea(db *gorm.DB, opts ...gen.DOOption) area {
_area := area{}
_area.areaDo.UseDB(db, opts...)
_area.areaDo.UseModel(&models.Area{})
tableName := _area.areaDo.TableName()
_area.ALL = field.NewAsterisk(tableName)
_area.ID = field.NewInt32(tableName, "id")
_area.CreatedAt = field.NewTime(tableName, "created_at")
_area.UpdatedAt = field.NewTime(tableName, "updated_at")
_area.DeletedAt = field.NewField(tableName, "deleted_at")
_area.Name = field.NewString(tableName, "name")
_area.Level = field.NewInt(tableName, "level")
_area.ParentID = field.NewInt32(tableName, "parent_id")
_area.Parent = areaBelongsToParent{
db: db.Session(&gorm.Session{}),
RelationField: field.NewRelation("Parent", "models.Area"),
Parent: struct {
field.RelationField
}{
RelationField: field.NewRelation("Parent.Parent", "models.Area"),
},
}
_area.fillFieldMap()
return _area
}
type area struct {
areaDo
ALL field.Asterisk
ID field.Int32
CreatedAt field.Time
UpdatedAt field.Time
DeletedAt field.Field
Name field.String
Level field.Int
ParentID field.Int32
Parent areaBelongsToParent
fieldMap map[string]field.Expr
}
func (a area) Table(newTableName string) *area {
a.areaDo.UseTable(newTableName)
return a.updateTableName(newTableName)
}
func (a area) As(alias string) *area {
a.areaDo.DO = *(a.areaDo.As(alias).(*gen.DO))
return a.updateTableName(alias)
}
func (a *area) updateTableName(table string) *area {
a.ALL = field.NewAsterisk(table)
a.ID = field.NewInt32(table, "id")
a.CreatedAt = field.NewTime(table, "created_at")
a.UpdatedAt = field.NewTime(table, "updated_at")
a.DeletedAt = field.NewField(table, "deleted_at")
a.Name = field.NewString(table, "name")
a.Level = field.NewInt(table, "level")
a.ParentID = field.NewInt32(table, "parent_id")
a.fillFieldMap()
return a
}
func (a *area) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
_f, ok := a.fieldMap[fieldName]
if !ok || _f == nil {
return nil, false
}
_oe, ok := _f.(field.OrderExpr)
return _oe, ok
}
func (a *area) fillFieldMap() {
a.fieldMap = make(map[string]field.Expr, 8)
a.fieldMap["id"] = a.ID
a.fieldMap["created_at"] = a.CreatedAt
a.fieldMap["updated_at"] = a.UpdatedAt
a.fieldMap["deleted_at"] = a.DeletedAt
a.fieldMap["name"] = a.Name
a.fieldMap["level"] = a.Level
a.fieldMap["parent_id"] = a.ParentID
}
func (a area) clone(db *gorm.DB) area {
a.areaDo.ReplaceConnPool(db.Statement.ConnPool)
a.Parent.db = db.Session(&gorm.Session{Initialized: true})
a.Parent.db.Statement.ConnPool = db.Statement.ConnPool
return a
}
func (a area) replaceDB(db *gorm.DB) area {
a.areaDo.ReplaceDB(db)
a.Parent.db = db.Session(&gorm.Session{})
return a
}
type areaBelongsToParent struct {
db *gorm.DB
field.RelationField
Parent struct {
field.RelationField
}
}
func (a areaBelongsToParent) Where(conds ...field.Expr) *areaBelongsToParent {
if len(conds) == 0 {
return &a
}
exprs := make([]clause.Expression, 0, len(conds))
for _, cond := range conds {
exprs = append(exprs, cond.BeCond().(clause.Expression))
}
a.db = a.db.Clauses(clause.Where{Exprs: exprs})
return &a
}
func (a areaBelongsToParent) WithContext(ctx context.Context) *areaBelongsToParent {
a.db = a.db.WithContext(ctx)
return &a
}
func (a areaBelongsToParent) Session(session *gorm.Session) *areaBelongsToParent {
a.db = a.db.Session(session)
return &a
}
func (a areaBelongsToParent) Model(m *models.Area) *areaBelongsToParentTx {
return &areaBelongsToParentTx{a.db.Model(m).Association(a.Name())}
}
func (a areaBelongsToParent) Unscoped() *areaBelongsToParent {
a.db = a.db.Unscoped()
return &a
}
type areaBelongsToParentTx struct{ tx *gorm.Association }
func (a areaBelongsToParentTx) Find() (result *models.Area, err error) {
return result, a.tx.Find(&result)
}
func (a areaBelongsToParentTx) Append(values ...*models.Area) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Append(targetValues...)
}
func (a areaBelongsToParentTx) Replace(values ...*models.Area) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Replace(targetValues...)
}
func (a areaBelongsToParentTx) Delete(values ...*models.Area) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Delete(targetValues...)
}
func (a areaBelongsToParentTx) Clear() error {
return a.tx.Clear()
}
func (a areaBelongsToParentTx) Count() int64 {
return a.tx.Count()
}
func (a areaBelongsToParentTx) Unscoped() *areaBelongsToParentTx {
a.tx = a.tx.Unscoped()
return &a
}
type areaDo struct{ gen.DO }
func (a areaDo) Debug() *areaDo {
return a.withDO(a.DO.Debug())
}
func (a areaDo) WithContext(ctx context.Context) *areaDo {
return a.withDO(a.DO.WithContext(ctx))
}
func (a areaDo) ReadDB() *areaDo {
return a.Clauses(dbresolver.Read)
}
func (a areaDo) WriteDB() *areaDo {
return a.Clauses(dbresolver.Write)
}
func (a areaDo) Session(config *gorm.Session) *areaDo {
return a.withDO(a.DO.Session(config))
}
func (a areaDo) Clauses(conds ...clause.Expression) *areaDo {
return a.withDO(a.DO.Clauses(conds...))
}
func (a areaDo) Returning(value interface{}, columns ...string) *areaDo {
return a.withDO(a.DO.Returning(value, columns...))
}
func (a areaDo) Not(conds ...gen.Condition) *areaDo {
return a.withDO(a.DO.Not(conds...))
}
func (a areaDo) Or(conds ...gen.Condition) *areaDo {
return a.withDO(a.DO.Or(conds...))
}
func (a areaDo) Select(conds ...field.Expr) *areaDo {
return a.withDO(a.DO.Select(conds...))
}
func (a areaDo) Where(conds ...gen.Condition) *areaDo {
return a.withDO(a.DO.Where(conds...))
}
func (a areaDo) Order(conds ...field.Expr) *areaDo {
return a.withDO(a.DO.Order(conds...))
}
func (a areaDo) Distinct(cols ...field.Expr) *areaDo {
return a.withDO(a.DO.Distinct(cols...))
}
func (a areaDo) Omit(cols ...field.Expr) *areaDo {
return a.withDO(a.DO.Omit(cols...))
}
func (a areaDo) Join(table schema.Tabler, on ...field.Expr) *areaDo {
return a.withDO(a.DO.Join(table, on...))
}
func (a areaDo) LeftJoin(table schema.Tabler, on ...field.Expr) *areaDo {
return a.withDO(a.DO.LeftJoin(table, on...))
}
func (a areaDo) RightJoin(table schema.Tabler, on ...field.Expr) *areaDo {
return a.withDO(a.DO.RightJoin(table, on...))
}
func (a areaDo) Group(cols ...field.Expr) *areaDo {
return a.withDO(a.DO.Group(cols...))
}
func (a areaDo) Having(conds ...gen.Condition) *areaDo {
return a.withDO(a.DO.Having(conds...))
}
func (a areaDo) Limit(limit int) *areaDo {
return a.withDO(a.DO.Limit(limit))
}
func (a areaDo) Offset(offset int) *areaDo {
return a.withDO(a.DO.Offset(offset))
}
func (a areaDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *areaDo {
return a.withDO(a.DO.Scopes(funcs...))
}
func (a areaDo) Unscoped() *areaDo {
return a.withDO(a.DO.Unscoped())
}
func (a areaDo) Create(values ...*models.Area) error {
if len(values) == 0 {
return nil
}
return a.DO.Create(values)
}
func (a areaDo) CreateInBatches(values []*models.Area, batchSize int) error {
return a.DO.CreateInBatches(values, batchSize)
}
// Save : !!! underlying implementation is different with GORM
// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values)
func (a areaDo) Save(values ...*models.Area) error {
if len(values) == 0 {
return nil
}
return a.DO.Save(values)
}
func (a areaDo) First() (*models.Area, error) {
if result, err := a.DO.First(); err != nil {
return nil, err
} else {
return result.(*models.Area), nil
}
}
func (a areaDo) Take() (*models.Area, error) {
if result, err := a.DO.Take(); err != nil {
return nil, err
} else {
return result.(*models.Area), nil
}
}
func (a areaDo) Last() (*models.Area, error) {
if result, err := a.DO.Last(); err != nil {
return nil, err
} else {
return result.(*models.Area), nil
}
}
func (a areaDo) Find() ([]*models.Area, error) {
result, err := a.DO.Find()
return result.([]*models.Area), err
}
func (a areaDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*models.Area, err error) {
buf := make([]*models.Area, 0, batchSize)
err = a.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error {
defer func() { results = append(results, buf...) }()
return fc(tx, batch)
})
return results, err
}
func (a areaDo) FindInBatches(result *[]*models.Area, batchSize int, fc func(tx gen.Dao, batch int) error) error {
return a.DO.FindInBatches(result, batchSize, fc)
}
func (a areaDo) Attrs(attrs ...field.AssignExpr) *areaDo {
return a.withDO(a.DO.Attrs(attrs...))
}
func (a areaDo) Assign(attrs ...field.AssignExpr) *areaDo {
return a.withDO(a.DO.Assign(attrs...))
}
func (a areaDo) Joins(fields ...field.RelationField) *areaDo {
for _, _f := range fields {
a = *a.withDO(a.DO.Joins(_f))
}
return &a
}
func (a areaDo) Preload(fields ...field.RelationField) *areaDo {
for _, _f := range fields {
a = *a.withDO(a.DO.Preload(_f))
}
return &a
}
func (a areaDo) FirstOrInit() (*models.Area, error) {
if result, err := a.DO.FirstOrInit(); err != nil {
return nil, err
} else {
return result.(*models.Area), nil
}
}
func (a areaDo) FirstOrCreate() (*models.Area, error) {
if result, err := a.DO.FirstOrCreate(); err != nil {
return nil, err
} else {
return result.(*models.Area), nil
}
}
func (a areaDo) FindByPage(offset int, limit int) (result []*models.Area, count int64, err error) {
result, err = a.Offset(offset).Limit(limit).Find()
if err != nil {
return
}
if size := len(result); 0 < limit && 0 < size && size < limit {
count = int64(size + offset)
return
}
count, err = a.Offset(-1).Limit(-1).Count()
return
}
func (a areaDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
count, err = a.Count()
if err != nil {
return
}
err = a.Offset(offset).Limit(limit).Scan(result)
return
}
func (a areaDo) Scan(result interface{}) (err error) {
return a.DO.Scan(result)
}
func (a areaDo) Delete(models ...*models.Area) (result gen.ResultInfo, err error) {
return a.DO.Delete(models)
}
func (a *areaDo) withDO(do gen.Dao) *areaDo {
a.DO = *do.(*gen.DO)
return a
}

442
web/queries/article.gen.go Normal file
View File

@@ -0,0 +1,442 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package queries
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
"gorm.io/gen"
"gorm.io/gen/field"
"gorm.io/plugin/dbresolver"
"platform/web/models"
)
func newArticle(db *gorm.DB, opts ...gen.DOOption) article {
_article := article{}
_article.articleDo.UseDB(db, opts...)
_article.articleDo.UseModel(&models.Article{})
tableName := _article.articleDo.TableName()
_article.ALL = field.NewAsterisk(tableName)
_article.ID = field.NewInt32(tableName, "id")
_article.CreatedAt = field.NewTime(tableName, "created_at")
_article.UpdatedAt = field.NewTime(tableName, "updated_at")
_article.DeletedAt = field.NewField(tableName, "deleted_at")
_article.GroupID = field.NewInt32(tableName, "group_id")
_article.Title = field.NewString(tableName, "title")
_article.Content = field.NewString(tableName, "content")
_article.Sort = field.NewInt32(tableName, "sort")
_article.Status = field.NewInt(tableName, "status")
_article.Group = articleBelongsToGroup{
db: db.Session(&gorm.Session{}),
RelationField: field.NewRelation("Group", "models.ArticleGroup"),
}
_article.fillFieldMap()
return _article
}
type article struct {
articleDo
ALL field.Asterisk
ID field.Int32
CreatedAt field.Time
UpdatedAt field.Time
DeletedAt field.Field
GroupID field.Int32
Title field.String
Content field.String
Sort field.Int32
Status field.Int
Group articleBelongsToGroup
fieldMap map[string]field.Expr
}
func (a article) Table(newTableName string) *article {
a.articleDo.UseTable(newTableName)
return a.updateTableName(newTableName)
}
func (a article) As(alias string) *article {
a.articleDo.DO = *(a.articleDo.As(alias).(*gen.DO))
return a.updateTableName(alias)
}
func (a *article) updateTableName(table string) *article {
a.ALL = field.NewAsterisk(table)
a.ID = field.NewInt32(table, "id")
a.CreatedAt = field.NewTime(table, "created_at")
a.UpdatedAt = field.NewTime(table, "updated_at")
a.DeletedAt = field.NewField(table, "deleted_at")
a.GroupID = field.NewInt32(table, "group_id")
a.Title = field.NewString(table, "title")
a.Content = field.NewString(table, "content")
a.Sort = field.NewInt32(table, "sort")
a.Status = field.NewInt(table, "status")
a.fillFieldMap()
return a
}
func (a *article) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
_f, ok := a.fieldMap[fieldName]
if !ok || _f == nil {
return nil, false
}
_oe, ok := _f.(field.OrderExpr)
return _oe, ok
}
func (a *article) fillFieldMap() {
a.fieldMap = make(map[string]field.Expr, 10)
a.fieldMap["id"] = a.ID
a.fieldMap["created_at"] = a.CreatedAt
a.fieldMap["updated_at"] = a.UpdatedAt
a.fieldMap["deleted_at"] = a.DeletedAt
a.fieldMap["group_id"] = a.GroupID
a.fieldMap["title"] = a.Title
a.fieldMap["content"] = a.Content
a.fieldMap["sort"] = a.Sort
a.fieldMap["status"] = a.Status
}
func (a article) clone(db *gorm.DB) article {
a.articleDo.ReplaceConnPool(db.Statement.ConnPool)
a.Group.db = db.Session(&gorm.Session{Initialized: true})
a.Group.db.Statement.ConnPool = db.Statement.ConnPool
return a
}
func (a article) replaceDB(db *gorm.DB) article {
a.articleDo.ReplaceDB(db)
a.Group.db = db.Session(&gorm.Session{})
return a
}
type articleBelongsToGroup struct {
db *gorm.DB
field.RelationField
}
func (a articleBelongsToGroup) Where(conds ...field.Expr) *articleBelongsToGroup {
if len(conds) == 0 {
return &a
}
exprs := make([]clause.Expression, 0, len(conds))
for _, cond := range conds {
exprs = append(exprs, cond.BeCond().(clause.Expression))
}
a.db = a.db.Clauses(clause.Where{Exprs: exprs})
return &a
}
func (a articleBelongsToGroup) WithContext(ctx context.Context) *articleBelongsToGroup {
a.db = a.db.WithContext(ctx)
return &a
}
func (a articleBelongsToGroup) Session(session *gorm.Session) *articleBelongsToGroup {
a.db = a.db.Session(session)
return &a
}
func (a articleBelongsToGroup) Model(m *models.Article) *articleBelongsToGroupTx {
return &articleBelongsToGroupTx{a.db.Model(m).Association(a.Name())}
}
func (a articleBelongsToGroup) Unscoped() *articleBelongsToGroup {
a.db = a.db.Unscoped()
return &a
}
type articleBelongsToGroupTx struct{ tx *gorm.Association }
func (a articleBelongsToGroupTx) Find() (result *models.ArticleGroup, err error) {
return result, a.tx.Find(&result)
}
func (a articleBelongsToGroupTx) Append(values ...*models.ArticleGroup) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Append(targetValues...)
}
func (a articleBelongsToGroupTx) Replace(values ...*models.ArticleGroup) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Replace(targetValues...)
}
func (a articleBelongsToGroupTx) Delete(values ...*models.ArticleGroup) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Delete(targetValues...)
}
func (a articleBelongsToGroupTx) Clear() error {
return a.tx.Clear()
}
func (a articleBelongsToGroupTx) Count() int64 {
return a.tx.Count()
}
func (a articleBelongsToGroupTx) Unscoped() *articleBelongsToGroupTx {
a.tx = a.tx.Unscoped()
return &a
}
type articleDo struct{ gen.DO }
func (a articleDo) Debug() *articleDo {
return a.withDO(a.DO.Debug())
}
func (a articleDo) WithContext(ctx context.Context) *articleDo {
return a.withDO(a.DO.WithContext(ctx))
}
func (a articleDo) ReadDB() *articleDo {
return a.Clauses(dbresolver.Read)
}
func (a articleDo) WriteDB() *articleDo {
return a.Clauses(dbresolver.Write)
}
func (a articleDo) Session(config *gorm.Session) *articleDo {
return a.withDO(a.DO.Session(config))
}
func (a articleDo) Clauses(conds ...clause.Expression) *articleDo {
return a.withDO(a.DO.Clauses(conds...))
}
func (a articleDo) Returning(value interface{}, columns ...string) *articleDo {
return a.withDO(a.DO.Returning(value, columns...))
}
func (a articleDo) Not(conds ...gen.Condition) *articleDo {
return a.withDO(a.DO.Not(conds...))
}
func (a articleDo) Or(conds ...gen.Condition) *articleDo {
return a.withDO(a.DO.Or(conds...))
}
func (a articleDo) Select(conds ...field.Expr) *articleDo {
return a.withDO(a.DO.Select(conds...))
}
func (a articleDo) Where(conds ...gen.Condition) *articleDo {
return a.withDO(a.DO.Where(conds...))
}
func (a articleDo) Order(conds ...field.Expr) *articleDo {
return a.withDO(a.DO.Order(conds...))
}
func (a articleDo) Distinct(cols ...field.Expr) *articleDo {
return a.withDO(a.DO.Distinct(cols...))
}
func (a articleDo) Omit(cols ...field.Expr) *articleDo {
return a.withDO(a.DO.Omit(cols...))
}
func (a articleDo) Join(table schema.Tabler, on ...field.Expr) *articleDo {
return a.withDO(a.DO.Join(table, on...))
}
func (a articleDo) LeftJoin(table schema.Tabler, on ...field.Expr) *articleDo {
return a.withDO(a.DO.LeftJoin(table, on...))
}
func (a articleDo) RightJoin(table schema.Tabler, on ...field.Expr) *articleDo {
return a.withDO(a.DO.RightJoin(table, on...))
}
func (a articleDo) Group(cols ...field.Expr) *articleDo {
return a.withDO(a.DO.Group(cols...))
}
func (a articleDo) Having(conds ...gen.Condition) *articleDo {
return a.withDO(a.DO.Having(conds...))
}
func (a articleDo) Limit(limit int) *articleDo {
return a.withDO(a.DO.Limit(limit))
}
func (a articleDo) Offset(offset int) *articleDo {
return a.withDO(a.DO.Offset(offset))
}
func (a articleDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *articleDo {
return a.withDO(a.DO.Scopes(funcs...))
}
func (a articleDo) Unscoped() *articleDo {
return a.withDO(a.DO.Unscoped())
}
func (a articleDo) Create(values ...*models.Article) error {
if len(values) == 0 {
return nil
}
return a.DO.Create(values)
}
func (a articleDo) CreateInBatches(values []*models.Article, batchSize int) error {
return a.DO.CreateInBatches(values, batchSize)
}
// Save : !!! underlying implementation is different with GORM
// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values)
func (a articleDo) Save(values ...*models.Article) error {
if len(values) == 0 {
return nil
}
return a.DO.Save(values)
}
func (a articleDo) First() (*models.Article, error) {
if result, err := a.DO.First(); err != nil {
return nil, err
} else {
return result.(*models.Article), nil
}
}
func (a articleDo) Take() (*models.Article, error) {
if result, err := a.DO.Take(); err != nil {
return nil, err
} else {
return result.(*models.Article), nil
}
}
func (a articleDo) Last() (*models.Article, error) {
if result, err := a.DO.Last(); err != nil {
return nil, err
} else {
return result.(*models.Article), nil
}
}
func (a articleDo) Find() ([]*models.Article, error) {
result, err := a.DO.Find()
return result.([]*models.Article), err
}
func (a articleDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*models.Article, err error) {
buf := make([]*models.Article, 0, batchSize)
err = a.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error {
defer func() { results = append(results, buf...) }()
return fc(tx, batch)
})
return results, err
}
func (a articleDo) FindInBatches(result *[]*models.Article, batchSize int, fc func(tx gen.Dao, batch int) error) error {
return a.DO.FindInBatches(result, batchSize, fc)
}
func (a articleDo) Attrs(attrs ...field.AssignExpr) *articleDo {
return a.withDO(a.DO.Attrs(attrs...))
}
func (a articleDo) Assign(attrs ...field.AssignExpr) *articleDo {
return a.withDO(a.DO.Assign(attrs...))
}
func (a articleDo) Joins(fields ...field.RelationField) *articleDo {
for _, _f := range fields {
a = *a.withDO(a.DO.Joins(_f))
}
return &a
}
func (a articleDo) Preload(fields ...field.RelationField) *articleDo {
for _, _f := range fields {
a = *a.withDO(a.DO.Preload(_f))
}
return &a
}
func (a articleDo) FirstOrInit() (*models.Article, error) {
if result, err := a.DO.FirstOrInit(); err != nil {
return nil, err
} else {
return result.(*models.Article), nil
}
}
func (a articleDo) FirstOrCreate() (*models.Article, error) {
if result, err := a.DO.FirstOrCreate(); err != nil {
return nil, err
} else {
return result.(*models.Article), nil
}
}
func (a articleDo) FindByPage(offset int, limit int) (result []*models.Article, count int64, err error) {
result, err = a.Offset(offset).Limit(limit).Find()
if err != nil {
return
}
if size := len(result); 0 < limit && 0 < size && size < limit {
count = int64(size + offset)
return
}
count, err = a.Offset(-1).Limit(-1).Count()
return
}
func (a articleDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
count, err = a.Count()
if err != nil {
return
}
err = a.Offset(offset).Limit(limit).Scan(result)
return
}
func (a articleDo) Scan(result interface{}) (err error) {
return a.DO.Scan(result)
}
func (a articleDo) Delete(models ...*models.Article) (result gen.ResultInfo, err error) {
return a.DO.Delete(models)
}
func (a *articleDo) withDO(do gen.Dao) *articleDo {
a.DO = *do.(*gen.DO)
return a
}

View File

@@ -0,0 +1,347 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package queries
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
"gorm.io/gen"
"gorm.io/gen/field"
"gorm.io/plugin/dbresolver"
"platform/web/models"
)
func newArticleGroup(db *gorm.DB, opts ...gen.DOOption) articleGroup {
_articleGroup := articleGroup{}
_articleGroup.articleGroupDo.UseDB(db, opts...)
_articleGroup.articleGroupDo.UseModel(&models.ArticleGroup{})
tableName := _articleGroup.articleGroupDo.TableName()
_articleGroup.ALL = field.NewAsterisk(tableName)
_articleGroup.ID = field.NewInt32(tableName, "id")
_articleGroup.CreatedAt = field.NewTime(tableName, "created_at")
_articleGroup.UpdatedAt = field.NewTime(tableName, "updated_at")
_articleGroup.DeletedAt = field.NewField(tableName, "deleted_at")
_articleGroup.Name = field.NewString(tableName, "name")
_articleGroup.Code = field.NewString(tableName, "code")
_articleGroup.Sort = field.NewInt32(tableName, "sort")
_articleGroup.Status = field.NewInt(tableName, "status")
_articleGroup.fillFieldMap()
return _articleGroup
}
type articleGroup struct {
articleGroupDo
ALL field.Asterisk
ID field.Int32
CreatedAt field.Time
UpdatedAt field.Time
DeletedAt field.Field
Name field.String
Code field.String
Sort field.Int32
Status field.Int
fieldMap map[string]field.Expr
}
func (a articleGroup) Table(newTableName string) *articleGroup {
a.articleGroupDo.UseTable(newTableName)
return a.updateTableName(newTableName)
}
func (a articleGroup) As(alias string) *articleGroup {
a.articleGroupDo.DO = *(a.articleGroupDo.As(alias).(*gen.DO))
return a.updateTableName(alias)
}
func (a *articleGroup) updateTableName(table string) *articleGroup {
a.ALL = field.NewAsterisk(table)
a.ID = field.NewInt32(table, "id")
a.CreatedAt = field.NewTime(table, "created_at")
a.UpdatedAt = field.NewTime(table, "updated_at")
a.DeletedAt = field.NewField(table, "deleted_at")
a.Name = field.NewString(table, "name")
a.Code = field.NewString(table, "code")
a.Sort = field.NewInt32(table, "sort")
a.Status = field.NewInt(table, "status")
a.fillFieldMap()
return a
}
func (a *articleGroup) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
_f, ok := a.fieldMap[fieldName]
if !ok || _f == nil {
return nil, false
}
_oe, ok := _f.(field.OrderExpr)
return _oe, ok
}
func (a *articleGroup) fillFieldMap() {
a.fieldMap = make(map[string]field.Expr, 8)
a.fieldMap["id"] = a.ID
a.fieldMap["created_at"] = a.CreatedAt
a.fieldMap["updated_at"] = a.UpdatedAt
a.fieldMap["deleted_at"] = a.DeletedAt
a.fieldMap["name"] = a.Name
a.fieldMap["code"] = a.Code
a.fieldMap["sort"] = a.Sort
a.fieldMap["status"] = a.Status
}
func (a articleGroup) clone(db *gorm.DB) articleGroup {
a.articleGroupDo.ReplaceConnPool(db.Statement.ConnPool)
return a
}
func (a articleGroup) replaceDB(db *gorm.DB) articleGroup {
a.articleGroupDo.ReplaceDB(db)
return a
}
type articleGroupDo struct{ gen.DO }
func (a articleGroupDo) Debug() *articleGroupDo {
return a.withDO(a.DO.Debug())
}
func (a articleGroupDo) WithContext(ctx context.Context) *articleGroupDo {
return a.withDO(a.DO.WithContext(ctx))
}
func (a articleGroupDo) ReadDB() *articleGroupDo {
return a.Clauses(dbresolver.Read)
}
func (a articleGroupDo) WriteDB() *articleGroupDo {
return a.Clauses(dbresolver.Write)
}
func (a articleGroupDo) Session(config *gorm.Session) *articleGroupDo {
return a.withDO(a.DO.Session(config))
}
func (a articleGroupDo) Clauses(conds ...clause.Expression) *articleGroupDo {
return a.withDO(a.DO.Clauses(conds...))
}
func (a articleGroupDo) Returning(value interface{}, columns ...string) *articleGroupDo {
return a.withDO(a.DO.Returning(value, columns...))
}
func (a articleGroupDo) Not(conds ...gen.Condition) *articleGroupDo {
return a.withDO(a.DO.Not(conds...))
}
func (a articleGroupDo) Or(conds ...gen.Condition) *articleGroupDo {
return a.withDO(a.DO.Or(conds...))
}
func (a articleGroupDo) Select(conds ...field.Expr) *articleGroupDo {
return a.withDO(a.DO.Select(conds...))
}
func (a articleGroupDo) Where(conds ...gen.Condition) *articleGroupDo {
return a.withDO(a.DO.Where(conds...))
}
func (a articleGroupDo) Order(conds ...field.Expr) *articleGroupDo {
return a.withDO(a.DO.Order(conds...))
}
func (a articleGroupDo) Distinct(cols ...field.Expr) *articleGroupDo {
return a.withDO(a.DO.Distinct(cols...))
}
func (a articleGroupDo) Omit(cols ...field.Expr) *articleGroupDo {
return a.withDO(a.DO.Omit(cols...))
}
func (a articleGroupDo) Join(table schema.Tabler, on ...field.Expr) *articleGroupDo {
return a.withDO(a.DO.Join(table, on...))
}
func (a articleGroupDo) LeftJoin(table schema.Tabler, on ...field.Expr) *articleGroupDo {
return a.withDO(a.DO.LeftJoin(table, on...))
}
func (a articleGroupDo) RightJoin(table schema.Tabler, on ...field.Expr) *articleGroupDo {
return a.withDO(a.DO.RightJoin(table, on...))
}
func (a articleGroupDo) Group(cols ...field.Expr) *articleGroupDo {
return a.withDO(a.DO.Group(cols...))
}
func (a articleGroupDo) Having(conds ...gen.Condition) *articleGroupDo {
return a.withDO(a.DO.Having(conds...))
}
func (a articleGroupDo) Limit(limit int) *articleGroupDo {
return a.withDO(a.DO.Limit(limit))
}
func (a articleGroupDo) Offset(offset int) *articleGroupDo {
return a.withDO(a.DO.Offset(offset))
}
func (a articleGroupDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *articleGroupDo {
return a.withDO(a.DO.Scopes(funcs...))
}
func (a articleGroupDo) Unscoped() *articleGroupDo {
return a.withDO(a.DO.Unscoped())
}
func (a articleGroupDo) Create(values ...*models.ArticleGroup) error {
if len(values) == 0 {
return nil
}
return a.DO.Create(values)
}
func (a articleGroupDo) CreateInBatches(values []*models.ArticleGroup, batchSize int) error {
return a.DO.CreateInBatches(values, batchSize)
}
// Save : !!! underlying implementation is different with GORM
// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values)
func (a articleGroupDo) Save(values ...*models.ArticleGroup) error {
if len(values) == 0 {
return nil
}
return a.DO.Save(values)
}
func (a articleGroupDo) First() (*models.ArticleGroup, error) {
if result, err := a.DO.First(); err != nil {
return nil, err
} else {
return result.(*models.ArticleGroup), nil
}
}
func (a articleGroupDo) Take() (*models.ArticleGroup, error) {
if result, err := a.DO.Take(); err != nil {
return nil, err
} else {
return result.(*models.ArticleGroup), nil
}
}
func (a articleGroupDo) Last() (*models.ArticleGroup, error) {
if result, err := a.DO.Last(); err != nil {
return nil, err
} else {
return result.(*models.ArticleGroup), nil
}
}
func (a articleGroupDo) Find() ([]*models.ArticleGroup, error) {
result, err := a.DO.Find()
return result.([]*models.ArticleGroup), err
}
func (a articleGroupDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*models.ArticleGroup, err error) {
buf := make([]*models.ArticleGroup, 0, batchSize)
err = a.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error {
defer func() { results = append(results, buf...) }()
return fc(tx, batch)
})
return results, err
}
func (a articleGroupDo) FindInBatches(result *[]*models.ArticleGroup, batchSize int, fc func(tx gen.Dao, batch int) error) error {
return a.DO.FindInBatches(result, batchSize, fc)
}
func (a articleGroupDo) Attrs(attrs ...field.AssignExpr) *articleGroupDo {
return a.withDO(a.DO.Attrs(attrs...))
}
func (a articleGroupDo) Assign(attrs ...field.AssignExpr) *articleGroupDo {
return a.withDO(a.DO.Assign(attrs...))
}
func (a articleGroupDo) Joins(fields ...field.RelationField) *articleGroupDo {
for _, _f := range fields {
a = *a.withDO(a.DO.Joins(_f))
}
return &a
}
func (a articleGroupDo) Preload(fields ...field.RelationField) *articleGroupDo {
for _, _f := range fields {
a = *a.withDO(a.DO.Preload(_f))
}
return &a
}
func (a articleGroupDo) FirstOrInit() (*models.ArticleGroup, error) {
if result, err := a.DO.FirstOrInit(); err != nil {
return nil, err
} else {
return result.(*models.ArticleGroup), nil
}
}
func (a articleGroupDo) FirstOrCreate() (*models.ArticleGroup, error) {
if result, err := a.DO.FirstOrCreate(); err != nil {
return nil, err
} else {
return result.(*models.ArticleGroup), nil
}
}
func (a articleGroupDo) FindByPage(offset int, limit int) (result []*models.ArticleGroup, count int64, err error) {
result, err = a.Offset(offset).Limit(limit).Find()
if err != nil {
return
}
if size := len(result); 0 < limit && 0 < size && size < limit {
count = int64(size + offset)
return
}
count, err = a.Offset(-1).Limit(-1).Count()
return
}
func (a articleGroupDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
count, err = a.Count()
if err != nil {
return
}
err = a.Offset(offset).Limit(limit).Scan(result)
return
}
func (a articleGroupDo) Scan(result interface{}) (err error) {
return a.DO.Scan(result)
}
func (a articleGroupDo) Delete(models ...*models.ArticleGroup) (result gen.ResultInfo, err error) {
return a.DO.Delete(models)
}
func (a *articleGroupDo) withDO(do gen.Dao) *articleGroupDo {
a.DO = *do.(*gen.DO)
return a
}

View File

@@ -218,6 +218,12 @@ func newChannel(db *gorm.DB, opts ...gen.DOOption) channel {
}
Edge struct {
field.RelationField
Area struct {
field.RelationField
Parent struct {
field.RelationField
}
}
}
}{
RelationField: field.NewRelation("Proxy.Channels", "models.Channel"),
@@ -238,8 +244,27 @@ func newChannel(db *gorm.DB, opts ...gen.DOOption) channel {
},
Edge: struct {
field.RelationField
Area struct {
field.RelationField
Parent struct {
field.RelationField
}
}
}{
RelationField: field.NewRelation("Proxy.Channels.Edge", "models.Edge"),
Area: struct {
field.RelationField
Parent struct {
field.RelationField
}
}{
RelationField: field.NewRelation("Proxy.Channels.Edge.Area", "models.Area"),
Parent: struct {
field.RelationField
}{
RelationField: field.NewRelation("Proxy.Channels.Edge.Area.Parent", "models.Area"),
},
},
},
},
}
@@ -617,6 +642,12 @@ type channelBelongsToProxy struct {
}
Edge struct {
field.RelationField
Area struct {
field.RelationField
Parent struct {
field.RelationField
}
}
}
}
}

View File

@@ -35,12 +35,22 @@ func newEdge(db *gorm.DB, opts ...gen.DOOption) edge {
_edge.Version = field.NewInt32(tableName, "version")
_edge.Mac = field.NewString(tableName, "mac")
_edge.IP = field.NewField(tableName, "ip")
_edge.Port = field.NewUint16(tableName, "port")
_edge.ISP = field.NewInt(tableName, "isp")
_edge.Prov = field.NewString(tableName, "prov")
_edge.City = field.NewString(tableName, "city")
_edge.AreaID = field.NewInt32(tableName, "area_id")
_edge.Status = field.NewInt(tableName, "status")
_edge.RTT = field.NewInt32(tableName, "rtt")
_edge.Loss = field.NewInt32(tableName, "loss")
_edge.Area = edgeBelongsToArea{
db: db.Session(&gorm.Session{}),
RelationField: field.NewRelation("Area", "models.Area"),
Parent: struct {
field.RelationField
}{
RelationField: field.NewRelation("Area.Parent", "models.Area"),
},
}
_edge.fillFieldMap()
@@ -59,12 +69,13 @@ type edge struct {
Version field.Int32
Mac field.String
IP field.Field
Port field.Uint16
ISP field.Int
Prov field.String
City field.String
AreaID field.Int32
Status field.Int
RTT field.Int32
Loss field.Int32
Area edgeBelongsToArea
fieldMap map[string]field.Expr
}
@@ -89,9 +100,9 @@ func (e *edge) updateTableName(table string) *edge {
e.Version = field.NewInt32(table, "version")
e.Mac = field.NewString(table, "mac")
e.IP = field.NewField(table, "ip")
e.Port = field.NewUint16(table, "port")
e.ISP = field.NewInt(table, "isp")
e.Prov = field.NewString(table, "prov")
e.City = field.NewString(table, "city")
e.AreaID = field.NewInt32(table, "area_id")
e.Status = field.NewInt(table, "status")
e.RTT = field.NewInt32(table, "rtt")
e.Loss = field.NewInt32(table, "loss")
@@ -111,7 +122,7 @@ func (e *edge) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
}
func (e *edge) fillFieldMap() {
e.fieldMap = make(map[string]field.Expr, 14)
e.fieldMap = make(map[string]field.Expr, 15)
e.fieldMap["id"] = e.ID
e.fieldMap["created_at"] = e.CreatedAt
e.fieldMap["updated_at"] = e.UpdatedAt
@@ -120,24 +131,113 @@ func (e *edge) fillFieldMap() {
e.fieldMap["version"] = e.Version
e.fieldMap["mac"] = e.Mac
e.fieldMap["ip"] = e.IP
e.fieldMap["port"] = e.Port
e.fieldMap["isp"] = e.ISP
e.fieldMap["prov"] = e.Prov
e.fieldMap["city"] = e.City
e.fieldMap["area_id"] = e.AreaID
e.fieldMap["status"] = e.Status
e.fieldMap["rtt"] = e.RTT
e.fieldMap["loss"] = e.Loss
}
func (e edge) clone(db *gorm.DB) edge {
e.edgeDo.ReplaceConnPool(db.Statement.ConnPool)
e.Area.db = db.Session(&gorm.Session{Initialized: true})
e.Area.db.Statement.ConnPool = db.Statement.ConnPool
return e
}
func (e edge) replaceDB(db *gorm.DB) edge {
e.edgeDo.ReplaceDB(db)
e.Area.db = db.Session(&gorm.Session{})
return e
}
type edgeBelongsToArea struct {
db *gorm.DB
field.RelationField
Parent struct {
field.RelationField
}
}
func (a edgeBelongsToArea) Where(conds ...field.Expr) *edgeBelongsToArea {
if len(conds) == 0 {
return &a
}
exprs := make([]clause.Expression, 0, len(conds))
for _, cond := range conds {
exprs = append(exprs, cond.BeCond().(clause.Expression))
}
a.db = a.db.Clauses(clause.Where{Exprs: exprs})
return &a
}
func (a edgeBelongsToArea) WithContext(ctx context.Context) *edgeBelongsToArea {
a.db = a.db.WithContext(ctx)
return &a
}
func (a edgeBelongsToArea) Session(session *gorm.Session) *edgeBelongsToArea {
a.db = a.db.Session(session)
return &a
}
func (a edgeBelongsToArea) Model(m *models.Edge) *edgeBelongsToAreaTx {
return &edgeBelongsToAreaTx{a.db.Model(m).Association(a.Name())}
}
func (a edgeBelongsToArea) Unscoped() *edgeBelongsToArea {
a.db = a.db.Unscoped()
return &a
}
type edgeBelongsToAreaTx struct{ tx *gorm.Association }
func (a edgeBelongsToAreaTx) Find() (result *models.Area, err error) {
return result, a.tx.Find(&result)
}
func (a edgeBelongsToAreaTx) Append(values ...*models.Area) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Append(targetValues...)
}
func (a edgeBelongsToAreaTx) Replace(values ...*models.Area) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Replace(targetValues...)
}
func (a edgeBelongsToAreaTx) Delete(values ...*models.Area) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Delete(targetValues...)
}
func (a edgeBelongsToAreaTx) Clear() error {
return a.tx.Clear()
}
func (a edgeBelongsToAreaTx) Count() int64 {
return a.tx.Count()
}
func (a edgeBelongsToAreaTx) Unscoped() *edgeBelongsToAreaTx {
a.tx = a.tx.Unscoped()
return &a
}
type edgeDo struct{ gen.DO }
func (e edgeDo) Debug() *edgeDo {

View File

@@ -20,6 +20,9 @@ var (
Admin *admin
AdminRole *adminRole
Announcement *announcement
Area *area
Article *article
ArticleGroup *articleGroup
BalanceActivity *balanceActivity
Bill *bill
Channel *channel
@@ -59,6 +62,9 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) {
Admin = &Q.Admin
AdminRole = &Q.AdminRole
Announcement = &Q.Announcement
Area = &Q.Area
Article = &Q.Article
ArticleGroup = &Q.ArticleGroup
BalanceActivity = &Q.BalanceActivity
Bill = &Q.Bill
Channel = &Q.Channel
@@ -99,6 +105,9 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
Admin: newAdmin(db, opts...),
AdminRole: newAdminRole(db, opts...),
Announcement: newAnnouncement(db, opts...),
Area: newArea(db, opts...),
Article: newArticle(db, opts...),
ArticleGroup: newArticleGroup(db, opts...),
BalanceActivity: newBalanceActivity(db, opts...),
Bill: newBill(db, opts...),
Channel: newChannel(db, opts...),
@@ -140,6 +149,9 @@ type Query struct {
Admin admin
AdminRole adminRole
Announcement announcement
Area area
Article article
ArticleGroup articleGroup
BalanceActivity balanceActivity
Bill bill
Channel channel
@@ -182,6 +194,9 @@ func (q *Query) clone(db *gorm.DB) *Query {
Admin: q.Admin.clone(db),
AdminRole: q.AdminRole.clone(db),
Announcement: q.Announcement.clone(db),
Area: q.Area.clone(db),
Article: q.Article.clone(db),
ArticleGroup: q.ArticleGroup.clone(db),
BalanceActivity: q.BalanceActivity.clone(db),
Bill: q.Bill.clone(db),
Channel: q.Channel.clone(db),
@@ -231,6 +246,9 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query {
Admin: q.Admin.replaceDB(db),
AdminRole: q.AdminRole.replaceDB(db),
Announcement: q.Announcement.replaceDB(db),
Area: q.Area.replaceDB(db),
Article: q.Article.replaceDB(db),
ArticleGroup: q.ArticleGroup.replaceDB(db),
BalanceActivity: q.BalanceActivity.replaceDB(db),
Bill: q.Bill.replaceDB(db),
Channel: q.Channel.replaceDB(db),
@@ -270,6 +288,9 @@ type queryCtx struct {
Admin *adminDo
AdminRole *adminRoleDo
Announcement *announcementDo
Area *areaDo
Article *articleDo
ArticleGroup *articleGroupDo
BalanceActivity *balanceActivityDo
Bill *billDo
Channel *channelDo
@@ -309,6 +330,9 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx {
Admin: q.Admin.WithContext(ctx),
AdminRole: q.AdminRole.WithContext(ctx),
Announcement: q.Announcement.WithContext(ctx),
Area: q.Area.WithContext(ctx),
Article: q.Article.WithContext(ctx),
ArticleGroup: q.ArticleGroup.WithContext(ctx),
BalanceActivity: q.BalanceActivity.WithContext(ctx),
Bill: q.Bill.WithContext(ctx),
Channel: q.Channel.WithContext(ctx),

View File

@@ -35,6 +35,7 @@ func newProxy(db *gorm.DB, opts ...gen.DOOption) proxy {
_proxy.Mac = field.NewString(tableName, "mac")
_proxy.IP = field.NewField(tableName, "ip")
_proxy.Host = field.NewString(tableName, "host")
_proxy.Port = field.NewInt(tableName, "port")
_proxy.Secret = field.NewString(tableName, "secret")
_proxy.Type = field.NewInt(tableName, "type")
_proxy.Status = field.NewInt(tableName, "status")
@@ -261,8 +262,27 @@ func newProxy(db *gorm.DB, opts ...gen.DOOption) proxy {
},
Edge: struct {
field.RelationField
Area struct {
field.RelationField
Parent struct {
field.RelationField
}
}
}{
RelationField: field.NewRelation("Channels.Edge", "models.Edge"),
Area: struct {
field.RelationField
Parent struct {
field.RelationField
}
}{
RelationField: field.NewRelation("Channels.Edge.Area", "models.Area"),
Parent: struct {
field.RelationField
}{
RelationField: field.NewRelation("Channels.Edge.Area.Parent", "models.Area"),
},
},
},
}
@@ -283,6 +303,7 @@ type proxy struct {
Mac field.String
IP field.Field
Host field.String
Port field.Int
Secret field.String
Type field.Int
Status field.Int
@@ -312,6 +333,7 @@ func (p *proxy) updateTableName(table string) *proxy {
p.Mac = field.NewString(table, "mac")
p.IP = field.NewField(table, "ip")
p.Host = field.NewString(table, "host")
p.Port = field.NewInt(table, "port")
p.Secret = field.NewString(table, "secret")
p.Type = field.NewInt(table, "type")
p.Status = field.NewInt(table, "status")
@@ -332,7 +354,7 @@ func (p *proxy) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
}
func (p *proxy) fillFieldMap() {
p.fieldMap = make(map[string]field.Expr, 13)
p.fieldMap = make(map[string]field.Expr, 14)
p.fieldMap["id"] = p.ID
p.fieldMap["created_at"] = p.CreatedAt
p.fieldMap["updated_at"] = p.UpdatedAt
@@ -341,6 +363,7 @@ func (p *proxy) fillFieldMap() {
p.fieldMap["mac"] = p.Mac
p.fieldMap["ip"] = p.IP
p.fieldMap["host"] = p.Host
p.fieldMap["port"] = p.Port
p.fieldMap["secret"] = p.Secret
p.fieldMap["type"] = p.Type
p.fieldMap["status"] = p.Status
@@ -431,6 +454,12 @@ type proxyHasManyChannels struct {
}
Edge struct {
field.RelationField
Area struct {
field.RelationField
Parent struct {
field.RelationField
}
}
}
}

View File

@@ -15,9 +15,10 @@ import (
func ApplyRouters(app *fiber.App) {
api := app.Group("/api")
publicRouter(api)
clientRouter(api)
userRouter(api)
adminRouter(api)
clientRouter(api)
// 回调
callbacks := app.Group("/callback")
@@ -38,7 +39,6 @@ func ApplyRouters(app *fiber.App) {
debug.Get("/test/err", func(ctx *fiber.Ctx) error {
return core.NewBizErr("测试错误")
})
debug.Get("/trade/status/:trade_no", func(ctx *fiber.Ctx) error {
tradeNo := ctx.Params("trade_no")
resp, err := globals.SFTPay.QueryTrade(&globals.QueryTradeReq{
@@ -52,8 +52,8 @@ func ApplyRouters(app *fiber.App) {
}
}
// 用户接口路由
func userRouter(api fiber.Router) {
// 公开接口路由
func publicRouter(api fiber.Router) {
// 认证
auth := api.Group("/auth")
auth.Get("/authorize", auth2.AuthorizeGet)
@@ -62,6 +62,43 @@ func userRouter(api fiber.Router) {
auth.Post("/revoke", auth2.Revoke)
auth.Post("/introspect", auth2.Introspect)
// 套餐
resource := api.Group("/resource")
resource.Post("/price", handlers.ResourcePrice)
// 交易
trade := api.Group("/trade")
trade.Get("/check", handlers.TradeCheck)
// 前台
inquiry := api.Group("/inquiry")
inquiry.Post("/create", handlers.CreateInquiry)
// 产品
product := api.Group("/product")
product.Post("/list", handlers.AllProduct)
}
// 客户端接口路由
func clientRouter(api fiber.Router) {
client := api
// 验证短信令牌
client.Post("/verify/sms", handlers.SendSmsCode)
// 网关
// 通道管理
channel := client.Group("/channel")
channel.Post("/remove", handlers.RemoveChannels)
// 文章查看
article := api.Group("/article")
article.Post("/nav", handlers.NavArticle)
article.Post("/get", handlers.GetArticle)
}
// 用户接口路由
func userRouter(api fiber.Router) {
// 用户
user := api.Group("/user")
user.Post("/update", handlers.UpdateUser)
@@ -96,13 +133,17 @@ func userRouter(api fiber.Router) {
channel.Post("/list", handlers.ListChannel)
channel.Post("/create", handlers.CreateChannel)
channel.Post("/create/v2", handlers.CreateChannelV2)
channel.Post("/create/v3", handlers.CreateChannelV3)
// 地区
area := api.Group("/area")
area.Post("/list", handlers.ListArea)
// 交易
trade := api.Group("/trade")
trade.Post("/create", handlers.TradeCreate)
trade.Post("/complete", handlers.TradeComplete)
trade.Post("/cancel", handlers.TradeCancel)
trade.Get("/check", handlers.TradeCheck)
// 账单
bill := api.Group("/bill")
@@ -121,47 +162,11 @@ func userRouter(api fiber.Router) {
announcement := api.Group("/announcement")
announcement.Post("/list", handlers.ListAnnouncements)
// 网关
proxy := api.Group("/proxy")
proxy.Post("/online", handlers.ProxyReportOnline)
proxy.Post("/offline", handlers.ProxyReportOffline)
proxy.Post("/update", handlers.ProxyReportUpdate)
// 节点
edge := api.Group("/edge")
edge.Post("/assign", handlers.AssignEdge)
edge.Post("/all", handlers.AllEdgesAvailable)
// 前台
inquiry := api.Group("/inquiry")
inquiry.Post("/create", handlers.CreateInquiry)
// 产品
product := api.Group("/product")
product.Post("/list", handlers.AllProduct)
// 认证
verify := api.Group("/verify")
verify.Post("/sms/password", handlers.SendSmsCodeForPassword)
}
// 客户端接口路由
func clientRouter(api fiber.Router) {
client := api
// 验证短信令牌
client.Post("/verify/sms", handlers.SendSmsCode)
// 套餐定价查询
resource := client.Group("/resource")
resource.Post("/price", handlers.ResourcePrice)
// 通道管理
channel := client.Group("/channel")
channel.Post("/remove", handlers.RemoveChannels)
}
// 管理员接口路由
func adminRouter(api fiber.Router) {
api = api.Group("/admin")
@@ -227,6 +232,8 @@ func adminRouter(api fiber.Router) {
proxy.Post("/create", handlers.CreateProxy)
proxy.Post("/update", handlers.UpdateProxy)
proxy.Post("/update/status", handlers.UpdateProxyStatus)
proxy.Post("/sync/ports", handlers.SyncProxyPorts)
proxy.Post("/sync/chains", handlers.SyncProxyChains)
proxy.Post("/remove", handlers.RemoveProxy)
// trade 交易
@@ -286,4 +293,21 @@ func adminRouter(api fiber.Router) {
couponUser.Post("/create", handlers.CreateCouponUserByAdmin)
couponUser.Post("/update", handlers.UpdateCouponUserByAdmin)
couponUser.Post("/remove", handlers.DeleteCouponUserByAdmin)
// article 文档
var article = api.Group("/article")
article.Post("/page", handlers.PageArticleByAdmin)
article.Post("/get", handlers.GetArticleByAdmin)
article.Post("/create", handlers.CreateArticle)
article.Post("/update", handlers.UpdateArticle)
article.Post("/remove", handlers.DeleteArticle)
article.Post("/upload", handlers.UploadArticleImage)
// article-group 文档分组
var articleGroup = api.Group("/article-group")
articleGroup.Post("/page", handlers.PageArticleGroupByAdmin)
articleGroup.Post("/list", handlers.ListArticleGroupByAdmin)
articleGroup.Post("/create", handlers.CreateArticleGroup)
articleGroup.Post("/update", handlers.UpdateArticleGroup)
articleGroup.Post("/remove", handlers.DeleteArticleGroup)
}

112
web/services/area.go Normal file
View File

@@ -0,0 +1,112 @@
package services
import (
"errors"
"platform/pkg/u"
"platform/web/core"
m "platform/web/models"
q "platform/web/queries"
"gorm.io/gorm"
)
var Area = &areaService{}
type areaService struct{}
func (s *areaService) ListAreas() ([]*m.Area, error) {
areas, err := q.Area.
Order(q.Area.Level, q.Area.ParentID, q.Area.ID).
Find()
if err != nil {
return nil, core.NewServErr("查询地区失败", err)
}
return areas, nil
}
func (s *areaService) FindIdByFilter(prov *string, city *string) (*int32, error) {
prov = u.N(prov)
city = u.N(city)
if prov == nil && city == nil {
return nil, nil
}
switch {
case prov != nil && city == nil:
area, err := q.Area.
Where(
q.Area.Level.Eq(int(m.AreaLevelProvince)),
q.Area.Name.Eq(*prov),
).
Order(q.Area.ID).
Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAreaNotExist
}
if err != nil {
return nil, core.NewServErr("查询地区失败", err)
}
return u.P(area.ID), nil
case prov == nil && city != nil:
area, err := q.Area.
Where(
q.Area.Level.Eq(int(m.AreaLevelCity)),
q.Area.Name.Eq(*city),
).
Order(q.Area.ID).
Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAreaNotExist
}
if err != nil {
return nil, core.NewServErr("查询地区失败", err)
}
return u.P(area.ID), nil
default:
province, err := q.Area.
Where(
q.Area.Level.Eq(int(m.AreaLevelProvince)),
q.Area.Name.Eq(*prov),
).
Order(q.Area.ID).
Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAreaNotExist
}
if err != nil {
return nil, core.NewServErr("查询地区失败", err)
}
area, err := q.Area.
Where(
q.Area.ParentID.Eq(province.ID),
q.Area.Level.Eq(int(m.AreaLevelCity)),
q.Area.Name.Eq(*city),
).
Order(q.Area.ID).
Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAreaNotExist
}
if err != nil {
return nil, core.NewServErr("查询地区失败", err)
}
return u.P(area.ID), nil
}
}
func (s *areaService) Get(id int32) (*m.Area, error) {
area, err := q.Area.
Preload(q.Area.Parent).
Where(q.Area.ID.Eq(id)).
Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAreaNotExist
}
if err != nil {
return nil, core.NewServErr("查询地区失败", err)
}
return area, nil
}
var ErrAreaNotExist = core.NewBizErr("地区不存在")

389
web/services/article.go Normal file
View File

@@ -0,0 +1,389 @@
package services
import (
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path"
"path/filepath"
"platform/pkg/env"
"platform/pkg/u"
"platform/web/core"
m "platform/web/models"
q "platform/web/queries"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gen/field"
"gorm.io/gorm"
)
var Article = &articleService{}
type articleService struct{}
var articleUploadMimeExt = map[string]string{
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
type ArticleUploadResult struct {
URL string `json:"url"`
Path string `json:"path"`
OriginalName string `json:"original_name"`
Size int64 `json:"size"`
MimeType string `json:"mime_type"`
}
func (s *articleService) UploadImage(fileHeader *multipart.FileHeader, baseURL string) (*ArticleUploadResult, error) {
if fileHeader == nil {
return nil, core.NewBizErr("缺少上传文件")
}
if fileHeader.Size > int64(env.ArticleUploadMaxBytes) {
return nil, core.NewBizErr(fmt.Sprintf("图片大小不能超过 %s", formatUploadSizeLimit(env.ArticleUploadMaxBytes)))
}
mimeType, ext, err := detectArticleImage(fileHeader)
if err != nil {
return nil, err
}
now := time.Now()
year := now.Format("2006")
month := now.Format("01")
fileName := uuid.NewString() + ext
relativePath := path.Join("/uploads", "article", year, month, fileName)
targetDir := filepath.Join(env.UploadDir, "article", year, month)
finalPath := filepath.Join(targetDir, fileName)
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return nil, core.NewServErr("创建上传目录失败", err)
}
src, err := fileHeader.Open()
if err != nil {
return nil, core.NewServErr("打开上传文件失败", err)
}
defer src.Close()
tmp, err := os.CreateTemp(targetDir, "upload-*"+ext)
if err != nil {
return nil, core.NewServErr("创建临时文件失败", err)
}
tmpPath := tmp.Name()
finished := false
defer func() {
if !finished {
_ = tmp.Close()
_ = os.Remove(tmpPath)
}
}()
limitedReader := &io.LimitedReader{R: src, N: int64(env.ArticleUploadMaxBytes) + 1}
written, err := io.Copy(tmp, limitedReader)
if err != nil {
return nil, core.NewServErr("保存上传文件失败", err)
}
if written > int64(env.ArticleUploadMaxBytes) {
return nil, core.NewBizErr(fmt.Sprintf("图片大小不能超过 %s", formatUploadSizeLimit(env.ArticleUploadMaxBytes)))
}
if err := tmp.Close(); err != nil {
return nil, core.NewServErr("关闭临时文件失败", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
return nil, core.NewServErr("保存上传文件失败", err)
}
finished = true
cleanBaseURL := strings.TrimRight(baseURL, "/")
url := relativePath
if cleanBaseURL != "" {
url = cleanBaseURL + relativePath
}
return &ArticleUploadResult{
URL: url,
Path: relativePath,
OriginalName: filepath.Base(fileHeader.Filename),
Size: written,
MimeType: mimeType,
}, nil
}
func detectArticleImage(fileHeader *multipart.FileHeader) (string, string, error) {
file, err := fileHeader.Open()
if err != nil {
return "", "", core.NewServErr("打开上传文件失败", err)
}
defer file.Close()
buf := make([]byte, 512)
n, err := io.ReadFull(file, buf)
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
return "", "", core.NewServErr("读取上传文件失败", err)
}
mimeType := http.DetectContentType(buf[:n])
ext, ok := articleUploadMimeExt[mimeType]
if !ok {
return "", "", core.NewBizErr("仅支持 JPG、PNG、WEBP、GIF 图片")
}
return mimeType, ext, nil
}
func formatUploadSizeLimit(bytes int) string {
if bytes%(1024*1024) == 0 {
return fmt.Sprintf("%d MB", bytes/(1024*1024))
}
if bytes%1024 == 0 {
return fmt.Sprintf("%d KB", bytes/1024)
}
return fmt.Sprintf("%d bytes", bytes)
}
func (s *articleService) Page(req *PageArticleReq) (result []*m.Article, count int64, err error) {
do := q.Article.Where()
if req.Keyword != nil && *req.Keyword != "" {
do = do.Where(q.Article.Title.Like("%" + *req.Keyword + "%"))
}
if req.GroupID != nil {
do = do.Where(q.Article.GroupID.Eq(*req.GroupID))
}
if req.Status != nil {
do = do.Where(q.Article.Status.Eq(int(*req.Status)))
}
return q.Article.
Preload(q.Article.Group).
Where(do).
Omit(q.Article.Content).
Order(q.Article.Sort, q.Article.CreatedAt).
FindByPage(req.GetOffset(), req.GetLimit())
}
type PageArticleReq struct {
core.PageReq
Keyword *string `json:"keyword,omitempty"`
GroupID *int32 `json:"group_id,omitempty"`
Status *m.ArticleStatus `json:"status,omitempty"`
}
func (s *articleService) GetByAdmin(id int32) (*m.Article, error) {
article, err := q.Article.
Preload(q.Article.Group).
Where(q.Article.ID.Eq(id)).
Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, core.NewBizErr("文档不存在")
}
if err != nil {
return nil, err
}
return article, nil
}
func (s *articleService) Create(data CreateArticleData) error {
if err := s.ensureGroupExists(data.GroupID); err != nil {
return err
}
return q.Article.Create(&m.Article{
GroupID: data.GroupID,
Title: data.Title,
Content: data.Content,
Sort: u.Else(data.Sort, 0),
Status: u.Else(data.Status, m.ArticleStatusEnabled),
})
}
type CreateArticleData struct {
GroupID int32 `json:"group_id" validate:"required"`
Title string `json:"title" validate:"required"`
Content *string `json:"content"`
Sort *int32 `json:"sort"`
Status *m.ArticleStatus `json:"status"`
}
func (s *articleService) Update(data UpdateArticleData) error {
if data.GroupID != nil {
if err := s.ensureGroupExists(*data.GroupID); err != nil {
return err
}
}
do := make([]field.AssignExpr, 0)
if data.GroupID != nil {
do = append(do, q.Article.GroupID.Value(*data.GroupID))
}
if data.Title != nil {
do = append(do, q.Article.Title.Value(*data.Title))
}
if data.Content != nil {
do = append(do, q.Article.Content.Value(*data.Content))
}
if data.Sort != nil {
do = append(do, q.Article.Sort.Value(*data.Sort))
}
if data.Status != nil {
do = append(do, q.Article.Status.Value(int(*data.Status)))
}
if len(do) == 0 {
return nil
}
r, err := q.Article.Where(q.Article.ID.Eq(data.ID)).UpdateSimple(do...)
if err != nil {
return err
}
if r.RowsAffected == 0 {
return core.NewBizErr("文档状态已过期")
}
return nil
}
type UpdateArticleData struct {
ID int32 `json:"id" validate:"required"`
GroupID *int32 `json:"group_id"`
Title *string `json:"title"`
Content *string `json:"content"`
Sort *int32 `json:"sort"`
Status *m.ArticleStatus `json:"status"`
}
func (s *articleService) Delete(id int32) error {
r, err := q.Article.Where(q.Article.ID.Eq(id)).UpdateColumn(q.Article.DeletedAt, time.Now())
if err != nil {
return err
}
if r.RowsAffected == 0 {
return core.NewBizErr("文档状态已过期")
}
return nil
}
func (s *articleService) Nav() ([]*ArticleNavGroup, error) {
groups, err := q.ArticleGroup.
Where(q.ArticleGroup.Status.Eq(int(m.ArticleGroupStatusEnabled))).
Order(q.ArticleGroup.Sort, q.ArticleGroup.CreatedAt).
Find()
if err != nil {
return nil, err
}
if len(groups) == 0 {
return []*ArticleNavGroup{}, nil
}
groupIDs := make([]int32, 0, len(groups))
result := make([]*ArticleNavGroup, 0, len(groups))
groupMap := make(map[int32]*ArticleNavGroup, len(groups))
for _, group := range groups {
groupIDs = append(groupIDs, group.ID)
item := &ArticleNavGroup{
ID: group.ID,
Name: group.Name,
Code: group.Code,
Articles: []*ArticleNavArticle{},
}
result = append(result, item)
groupMap[group.ID] = item
}
articles, err := q.Article.
Where(
q.Article.GroupID.In(groupIDs...),
q.Article.Status.Eq(int(m.ArticleStatusEnabled)),
).
Omit(q.Article.Content).
Order(q.Article.Sort, q.Article.CreatedAt).
Find()
if err != nil {
return nil, err
}
for _, article := range articles {
group := groupMap[article.GroupID]
if group == nil {
continue
}
group.Articles = append(group.Articles, &ArticleNavArticle{
ID: article.ID,
Title: article.Title,
UpdatedAt: article.UpdatedAt,
})
}
return result, nil
}
type ArticleNavGroup struct {
ID int32 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
Articles []*ArticleNavArticle `json:"articles"`
}
type ArticleNavArticle struct {
ID int32 `json:"id"`
Title string `json:"title"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) {
article, err := q.Article.
Preload(q.Article.Group).
Where(
q.Article.ID.Eq(id),
q.Article.Status.Eq(int(m.ArticleStatusEnabled)),
).
Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, core.NewBizErr("文档不存在")
}
if err != nil {
return nil, err
}
if article.Group == nil || article.Group.Status != m.ArticleGroupStatusEnabled {
return nil, core.NewBizErr("文档不存在")
}
return &ArticlePublicDetail{
ID: article.ID,
Title: article.Title,
Content: article.Content,
UpdatedAt: article.UpdatedAt,
Group: &ArticlePublicGroup{
ID: article.Group.ID,
Name: article.Group.Name,
Code: article.Group.Code,
},
}, nil
}
type ArticlePublicDetail struct {
ID int32 `json:"id"`
Title string `json:"title"`
Content *string `json:"content,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
Group *ArticlePublicGroup `json:"group"`
}
type ArticlePublicGroup struct {
ID int32 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
}
func (s *articleService) ensureGroupExists(groupID int32) error {
_, err := q.ArticleGroup.Where(q.ArticleGroup.ID.Eq(groupID)).Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("文档分组不存在")
}
return err
}

View File

@@ -0,0 +1,128 @@
package services
import (
"errors"
"platform/pkg/u"
"platform/web/core"
m "platform/web/models"
q "platform/web/queries"
"time"
"gorm.io/gen/field"
"gorm.io/gorm"
)
var ArticleGroup = &articleGroupService{}
type articleGroupService struct{}
func (s *articleGroupService) All() (result []*m.ArticleGroup, err error) {
return q.ArticleGroup.
Order(q.ArticleGroup.Sort, q.ArticleGroup.CreatedAt).
Find()
}
func (s *articleGroupService) Page(req *PageArticleGroupReq) (result []*m.ArticleGroup, count int64, err error) {
do := q.ArticleGroup.Where()
if req.Keyword != nil && *req.Keyword != "" {
do = do.Where(
q.ArticleGroup.Where(
q.ArticleGroup.Name.Like("%" + *req.Keyword + "%"),
).Or(
q.ArticleGroup.Code.Like("%" + *req.Keyword + "%"),
),
)
}
if req.Status != nil {
do = do.Where(q.ArticleGroup.Status.Eq(int(*req.Status)))
}
return q.ArticleGroup.
Where(do).
Order(q.ArticleGroup.Sort, q.ArticleGroup.CreatedAt).
FindByPage(req.GetOffset(), req.GetLimit())
}
type PageArticleGroupReq struct {
core.PageReq
Keyword *string `json:"keyword,omitempty"`
Status *m.ArticleGroupStatus `json:"status,omitempty"`
}
func (s *articleGroupService) Create(data CreateArticleGroupData) error {
err := q.ArticleGroup.Create(&m.ArticleGroup{
Name: data.Name,
Code: data.Code,
Sort: u.Else(data.Sort, 0),
Status: u.Else(data.Status, m.ArticleGroupStatusEnabled),
})
if errors.Is(err, gorm.ErrDuplicatedKey) {
return core.NewBizErr("文档分组编码已存在")
}
return err
}
type CreateArticleGroupData struct {
Name string `json:"name" validate:"required"`
Code string `json:"code" validate:"required"`
Sort *int32 `json:"sort"`
Status *m.ArticleGroupStatus `json:"status"`
}
func (s *articleGroupService) Update(data UpdateArticleGroupData) error {
do := make([]field.AssignExpr, 0)
if data.Name != nil {
do = append(do, q.ArticleGroup.Name.Value(*data.Name))
}
if data.Code != nil {
do = append(do, q.ArticleGroup.Code.Value(*data.Code))
}
if data.Sort != nil {
do = append(do, q.ArticleGroup.Sort.Value(*data.Sort))
}
if data.Status != nil {
do = append(do, q.ArticleGroup.Status.Value(int(*data.Status)))
}
if len(do) == 0 {
return nil
}
r, err := q.ArticleGroup.Where(q.ArticleGroup.ID.Eq(data.ID)).UpdateSimple(do...)
if errors.Is(err, gorm.ErrDuplicatedKey) {
return core.NewBizErr("文档分组编码已存在")
}
if err != nil {
return err
}
if r.RowsAffected == 0 {
return core.NewBizErr("文档分组状态已过期")
}
return nil
}
type UpdateArticleGroupData struct {
ID int32 `json:"id" validate:"required"`
Name *string `json:"name"`
Code *string `json:"code"`
Sort *int32 `json:"sort"`
Status *m.ArticleGroupStatus `json:"status"`
}
func (s *articleGroupService) Delete(id int32) error {
count, err := q.Article.Where(q.Article.GroupID.Eq(id)).Count()
if err != nil {
return err
}
if count > 0 {
return core.NewBizErr("分组下仍有关联文章,无法删除")
}
r, err := q.ArticleGroup.Where(q.ArticleGroup.ID.Eq(id)).UpdateColumn(q.ArticleGroup.DeletedAt, time.Now())
if err != nil {
return err
}
if r.RowsAffected == 0 {
return core.NewBizErr("文档分组状态已过期")
}
return nil
}

View File

@@ -0,0 +1,144 @@
package services
import (
"bytes"
"encoding/base64"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"platform/pkg/env"
)
func TestArticleUploadImageSuccess(t *testing.T) {
restore := snapshotUploadEnv()
defer restore()
env.UploadDir = t.TempDir()
env.ArticleUploadMaxBytes = 5 * 1024 * 1024
fileHeader := newMultipartFileHeader(t, "file", "pixel.png", mustDecodeBase64(t, onePixelPNGBase64))
result, err := Article.UploadImage(fileHeader, "https://example.com")
if err != nil {
t.Fatalf("UploadImage returned error: %v", err)
}
if result.MimeType != "image/png" {
t.Fatalf("unexpected mime type: %s", result.MimeType)
}
if !strings.HasPrefix(result.Path, "/uploads/article/") {
t.Fatalf("unexpected path: %s", result.Path)
}
if result.URL != "https://example.com"+result.Path {
t.Fatalf("unexpected url: %s", result.URL)
}
if result.OriginalName != "pixel.png" {
t.Fatalf("unexpected original name: %s", result.OriginalName)
}
savedPath := filepath.Join(env.UploadDir, filepath.FromSlash(strings.TrimPrefix(result.Path, "/uploads/")))
info, err := os.Stat(savedPath)
if err != nil {
t.Fatalf("saved file not found: %v", err)
}
if info.Size() != result.Size {
t.Fatalf("unexpected saved size: got %d want %d", info.Size(), result.Size)
}
}
func TestArticleUploadImageRejectsUnsupportedType(t *testing.T) {
restore := snapshotUploadEnv()
defer restore()
env.UploadDir = t.TempDir()
env.ArticleUploadMaxBytes = 5 * 1024 * 1024
fileHeader := newMultipartFileHeader(t, "file", "note.txt", []byte("not an image"))
_, err := Article.UploadImage(fileHeader, "https://example.com")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "仅支持 JPG、PNG、WEBP、GIF 图片") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestArticleUploadImageRejectsOversizeFile(t *testing.T) {
restore := snapshotUploadEnv()
defer restore()
env.UploadDir = t.TempDir()
env.ArticleUploadMaxBytes = 8
fileHeader := newMultipartFileHeader(t, "file", "large.png", bytes.Repeat([]byte("a"), 9))
_, err := Article.UploadImage(fileHeader, "https://example.com")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "图片大小不能超过") {
t.Fatalf("unexpected error: %v", err)
}
}
func newMultipartFileHeader(t *testing.T, fieldName string, fileName string, content []byte) *multipart.FileHeader {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile(fieldName, fileName)
if err != nil {
t.Fatalf("CreateFormFile failed: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("Write content failed: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("Close multipart writer failed: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
if err := req.ParseMultipartForm(int64(body.Len()) + 1024); err != nil {
t.Fatalf("ParseMultipartForm failed: %v", err)
}
file, fileHeader, err := req.FormFile(fieldName)
if err != nil {
t.Fatalf("FormFile failed: %v", err)
}
_ = file.Close()
return fileHeader
}
func mustDecodeBase64(t *testing.T, value string) []byte {
t.Helper()
data, err := base64.StdEncoding.DecodeString(value)
if err != nil {
t.Fatalf("DecodeString failed: %v", err)
}
return data
}
func snapshotUploadEnv() func() {
uploadDir := env.UploadDir
uploadPublicBaseURL := env.UploadPublicBaseURL
articleUploadMaxBytes := env.ArticleUploadMaxBytes
return func() {
env.UploadDir = uploadDir
env.UploadPublicBaseURL = uploadPublicBaseURL
env.ArticleUploadMaxBytes = articleUploadMaxBytes
}
}
const onePixelPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a7KQAAAAASUVORK5CYII="

View File

@@ -4,55 +4,414 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"math/rand/v2"
"net/netip"
"platform/pkg/env"
"platform/pkg/u"
"platform/web/core"
e "platform/web/events"
g "platform/web/globals"
"platform/web/globals/orm"
m "platform/web/models"
q "platform/web/queries"
"strconv"
"strings"
"time"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"gorm.io/gen"
"gorm.io/gen/field"
)
// 通道服务
var Channel = &channelServer{
provider: &channelBaiyinProvider{},
provider: &channelGostProvider{},
}
type ChannelServiceProvider interface {
CreateChannels(source netip.Addr, resourceNo string, authWhitelist bool, authPassword bool, count int, edgeFilter *EdgeFilter) ([]*m.Channel, error)
RemoveChannels(batch string) error
ClearExpiredChannels(proxyId int32) (int, error)
type channelProvider interface {
selectProxy(count int) (*m.Proxy, error)
prepareCreate(ctx *channelCreateContext) (*channelCreateResult, error)
removeRemote(batchNo string, batch *usedChanBatch) error
}
type channelServer struct {
provider ChannelServiceProvider
provider channelProvider
}
func (s *channelServer) CreateChannels(source netip.Addr, resourceNo string, authWhitelist bool, authPassword bool, count int, edgeFilter *EdgeFilter) ([]*m.Channel, error) {
return s.provider.CreateChannels(source, resourceNo, authWhitelist, authPassword, count, edgeFilter)
if edgeFilter == nil {
edgeFilter = &EdgeFilter{}
}
var area *m.Area
if edgeFilter.AreaID != nil {
var err error
area, err = Area.Get(*edgeFilter.AreaID)
if err != nil {
return nil, err
}
if err := validateChannelArea(area); err != nil {
return nil, err
}
}
now := time.Now()
batchNo := ID.GenReadable("bat")
var channels []*m.Channel
var whitelistText *string
err := g.Redsync.WithLock(lockChannelCreateKey(resourceNo), func() error {
resource, whitelists, err := ensure(now, source, resourceNo, authWhitelist, count)
if err != nil {
return err
}
if authWhitelist {
joined := strings.Join(whitelists, ",")
whitelistText = &joined
}
expire := now.Add(resource.Live)
proxy, err := s.provider.selectProxy(count)
if err != nil {
return err
}
ports, err := selectPorts(proxy.ID, batchNo, count, expire)
if err != nil {
return err
}
createCtx := &channelCreateContext{
Now: now,
Source: source,
Resource: resource,
Proxy: proxy,
BatchNo: batchNo,
Ports: ports,
Expire: expire,
Count: count,
Filter: edgeFilter,
Area: area,
AuthWhitelist: authWhitelist,
AuthPassword: authPassword,
Whitelists: whitelists,
WhitelistText: whitelistText,
}
result, err := s.provider.prepareCreate(createCtx)
if err != nil {
return err
}
if result.applyRemote != nil {
if err := result.applyRemote(); err != nil {
return err
}
}
if err := persistChannelCreate(createCtx, result.Channels); err != nil {
return err
}
channels = result.Channels
return nil
})
if err != nil {
return nil, err
}
return channels, nil
}
func (s *channelServer) RemoveChannels(batch string) error {
return s.provider.RemoveChannels(batch)
return g.Redsync.WithLock(lockChannelRemoveKey(batch), func() error {
start := time.Now()
usedBatch, err := findUsedChanBatch(batch)
if err != nil {
return err
}
if usedBatch == nil {
slog.Debug("通道为空,跳过清理", "batch", batch)
return nil
}
if err := s.provider.removeRemote(batch, usedBatch); err != nil {
return err
}
if err := freeChans(usedBatch.ProxyID, batch); err != nil {
return err
}
slog.Debug("清除通道配置", "proxy", usedBatch.ProxyID, "batch", batch, "duration", time.Since(start).String())
return nil
})
}
func (s *channelServer) ClearExpiredChannels(proxyId int32) (int, error) {
return s.provider.ClearExpiredChannels(proxyId)
batchSet, err := findExpiredChannelBatches(proxyId, time.Now())
if err != nil {
return 0, err
}
slog.Info("批量清理过期通道", "count", len(batchSet))
for batchNo := range batchSet {
if err := s.RemoveChannels(batchNo); err != nil {
slog.Error("清理过期通道失败", "batch", batchNo, "error", err)
}
}
return len(batchSet), nil
}
type channelCreateContext struct {
Now time.Time
Source netip.Addr
Resource *ResourceView
Proxy *m.Proxy
BatchNo string
Ports []netip.AddrPort
Expire time.Time
Count int
Filter *EdgeFilter
Area *m.Area
AuthWhitelist bool
AuthPassword bool
Whitelists []string
WhitelistText *string
}
type channelCreateResult struct {
Channels []*m.Channel
applyRemote func() error
}
func newBaseChannel(ctx *channelCreateContext, port uint16) *m.Channel {
prov, city := areaProvinceCity(ctx.Area)
return &m.Channel{
UserID: ctx.Resource.User.ID,
ResourceID: ctx.Resource.ID,
BatchNo: ctx.BatchNo,
ProxyID: ctx.Proxy.ID,
Host: u.Else(ctx.Proxy.Host, ctx.Proxy.IP.String()),
Port: port,
FilterISP: ctx.Filter.Isp,
FilterProv: prov,
FilterCity: city,
ExpiredAt: ctx.Expire,
Proxy: ctx.Proxy,
}
}
func applyChannelAuth(ctx *channelCreateContext, channel *m.Channel) (username string, password string, ok bool) {
if ctx.AuthWhitelist {
channel.Whitelists = ctx.WhitelistText
}
if !ctx.AuthPassword {
return "", "", false
}
username, password = genPassPair()
channel.Username = &username
channel.Password = &password
return username, password, true
}
func persistChannelCreate(ctx *channelCreateContext, channels []*m.Channel) error {
prov, city := areaProvinceCity(ctx.Area)
return q.Q.Transaction(func(tx *q.Query) error {
var (
result gen.ResultInfo
err error
)
switch ctx.Resource.Type {
case m.ResourceTypeShort:
result, err = tx.ResourceShort.
Where(
tx.ResourceShort.ID.Eq(*ctx.Resource.ShortId),
tx.ResourceShort.Used.Eq(ctx.Resource.Used),
tx.ResourceShort.Daily.Eq(ctx.Resource.Daily),
).
UpdateSimple(
tx.ResourceShort.Used.Add(int32(ctx.Count)),
tx.ResourceShort.Daily.Value(int32(ctx.Resource.Today+ctx.Count)),
tx.ResourceShort.LastAt.Value(ctx.Now),
)
case m.ResourceTypeLong:
result, err = tx.ResourceLong.
Where(
tx.ResourceLong.ID.Eq(*ctx.Resource.LongId),
tx.ResourceLong.Used.Eq(ctx.Resource.Used),
tx.ResourceLong.Daily.Eq(ctx.Resource.Daily),
).
UpdateSimple(
tx.ResourceLong.Used.Add(int32(ctx.Count)),
tx.ResourceLong.Daily.Value(int32(ctx.Resource.Today+ctx.Count)),
tx.ResourceLong.LastAt.Value(ctx.Now),
)
default:
return core.NewBizErr("套餐类型不正确,无法更新")
}
if err != nil {
return core.NewServErr("更新套餐使用记录失败", err)
}
if result.RowsAffected == 0 {
return core.NewBizErr("套餐状态已过期")
}
if err := tx.Channel.Omit(field.AssociationFields).Create(channels...); err != nil {
return core.NewServErr("保存通道失败", err)
}
if err := tx.LogsUserUsage.Create(&m.LogsUserUsage{
UserID: ctx.Resource.User.ID,
ResourceID: ctx.Resource.ID,
BatchNo: ctx.BatchNo,
Count: int32(ctx.Count),
ISP: u.X(ctx.Filter.Isp.String()),
Prov: u.Ternary(ctx.Filter.AreaID != nil, prov, nil),
City: u.Ternary(ctx.Filter.AreaID != nil, city, nil),
IP: orm.Inet{Addr: ctx.Source},
Time: ctx.Now,
}); err != nil {
return core.NewServErr("保存用户使用记录失败", err)
}
return nil
})
}
func validateChannelArea(area *m.Area) error {
if area == nil {
return nil
}
switch area.Level {
case m.AreaLevelProvince:
return nil
case m.AreaLevelCity:
if area.ParentID == nil || area.Parent == nil {
return core.NewServErr("地区数据异常", nil)
}
return nil
default:
return core.NewBizErr("地区层级不支持")
}
}
func areaProvinceCity(area *m.Area) (prov *string, city *string) {
if area == nil {
return nil, nil
}
switch area.Level {
case m.AreaLevelProvince:
return u.P(area.Name), nil
case m.AreaLevelCity:
return u.P(area.Parent.Name), u.P(area.Name)
default:
return nil, nil
}
}
func findExpiredChannelBatches(proxyId int32, now time.Time) (map[string]struct{}, error) {
keys, err := g.Redis.Keys(context.Background(), usedChansKey(proxyId, "*")).Result()
if err != nil {
return nil, core.NewServErr("查询使用中通道失败", err)
}
if len(keys) == 0 {
return map[string]struct{}{}, nil
}
batchList := make([]string, len(keys))
batchSet := make(map[string]struct{}, len(keys))
for i, key := range keys {
parsed, err := parseUsedChanKey(key)
if err != nil {
return nil, err
}
batchList[i] = parsed.BatchNo
batchSet[parsed.BatchNo] = struct{}{}
}
var batchQueried []struct{ BatchNo string }
err = q.Channel.
Select(q.Channel.BatchNo).
Where(
q.Channel.BatchNo.In(batchList...),
q.Channel.ExpiredAt.Gte(now.UTC()),
).
Group(q.Channel.BatchNo).
Scan(&batchQueried)
if err != nil {
return nil, core.NewServErr("查询过期通道失败", err)
}
for _, batch := range batchQueried {
delete(batchSet, batch.BatchNo)
}
return batchSet, nil
}
func lockChannelCreateKey(resourceNo string) string {
return fmt.Sprintf("platform:channel:create:%s", resourceNo)
}
func lockChannelRemoveKey(bid string) string {
return fmt.Sprintf("platform:batch:remove_expired:%s", bid)
}
func selectPorts(proxyId int32, batchNo string, count int, expire time.Time) ([]netip.AddrPort, error) {
chans, err := lockChans(proxyId, batchNo, count)
if err != nil {
return nil, core.NewBizErr("无可用通道,请稍后再试", err)
}
_, err = g.Asynq.Enqueue(
e.NewRemoveChannel(batchNo),
asynq.ProcessAt(expire),
)
if err != nil {
return nil, core.NewServErr("注册异步关闭通道任务失败", err)
}
return chans, nil
}
func selectProxyByType(proxyType m.ProxyType, count int) (*m.Proxy, error) {
proxies, err := q.Proxy.Where(
q.Proxy.Type.Eq(int(proxyType)),
q.Proxy.Status.Eq(int(m.ProxyStatusOnline)),
).Find()
if err != nil {
return nil, core.NewBizErr("获取可用代理失败", err)
}
if len(proxies) == 0 {
return nil, core.NewBizErr("无可用代理")
}
var bestProxy *m.Proxy
maxCount := -1
for _, proxy := range proxies {
idCount, err := g.Redis.SCard(context.Background(), freeChansKey(proxy.ID)).Result()
if err != nil {
return nil, core.NewServErr("查询可用通道数量失败", err)
}
if idCount > int64(maxCount) {
maxCount = int(idCount)
bestProxy = proxy
}
}
if maxCount < count {
return nil, core.NewBizErr("无空闲代理")
}
return bestProxy, nil
}
func (s *channelServer) RefreshEdges() error {
if env.RunMode != env.RunModeProd {
return nil
}
// 找到所有网关
// 仅白银网关支持边缘节点刷新GOST 不参与此流程。
proxies, err := q.Proxy.Where(
q.Proxy.Type.Eq(int(m.ProxyTypeBaiYin)),
q.Proxy.Status.Eq(int(m.ProxyStatusOnline)),
).Find()
if err != nil {
@@ -282,6 +641,91 @@ func usedChansKey(proxy int32, batch string) string {
return "channel:used:" + strconv.Itoa(int(proxy)) + ":" + batch
}
type usedChanBatch struct {
ProxyID int32
Chans []netip.AddrPort
}
type usedChanKey struct {
ProxyID int32
BatchNo string
}
func findUsedChanBatch(batch string) (*usedChanBatch, error) {
keys, err := g.Redis.Keys(context.Background(), "channel:used:*:"+batch).Result()
if err != nil {
return nil, core.NewServErr("查询使用中通道失败", err)
}
key, ok, err := selectUsedChanBatchKey(batch, keys)
if err != nil {
return nil, err
}
if !ok {
return nil, nil
}
chans, err := g.Redis.LRange(context.Background(), key, 0, -1).Result()
if err != nil {
return nil, core.NewServErr("查询使用中通道失败", err)
}
return parseUsedChanBatch(key, chans)
}
func selectUsedChanBatchKey(batch string, keys []string) (string, bool, error) {
switch len(keys) {
case 0:
return "", false, nil
case 1:
return keys[0], true, nil
default:
slog.Error("batchNo 全局唯一约束被破坏", "batch", batch, "keys", keys)
return "", false, core.NewServErr(
fmt.Sprintf("检测到重复 usedChans 键batchNo 全局唯一被破坏: %s", batch),
fmt.Errorf("keys=%s", strings.Join(keys, ",")),
)
}
}
func parseUsedChanBatch(key string, chans []string) (*usedChanBatch, error) {
parsed, err := parseUsedChanKey(key)
if err != nil {
return nil, err
}
addrs := make([]netip.AddrPort, len(chans))
for i, ch := range chans {
addr, err := netip.ParseAddrPort(ch)
if err != nil {
return nil, core.NewServErr(fmt.Sprintf("解析通道数据失败: %s", ch), err)
}
addrs[i] = addr
}
return &usedChanBatch{
ProxyID: parsed.ProxyID,
Chans: addrs,
}, nil
}
func parseUsedChanKey(key string) (*usedChanKey, error) {
parts := strings.Split(key, ":")
if len(parts) != 4 {
return nil, core.NewServErr(fmt.Sprintf("使用中通道键格式错误: %s", key), nil)
}
proxyID, err := strconv.Atoi(parts[2])
if err != nil {
return nil, core.NewServErr(fmt.Sprintf("使用中通道键格式错误: %s", key), err)
}
return &usedChanKey{
ProxyID: int32(proxyID),
BatchNo: parts[3],
}, nil
}
// 扩容通道
func regChans(proxy int32, chans []netip.AddrPort) error {
strs := make([]any, len(chans))
@@ -382,12 +826,24 @@ redis.call("DEL", batch_key)
return 1
`)
// 节点筛选条件
type EdgeFilter struct {
Isp *m.EdgeISP `json:"isp"`
AreaID *int32 `json:"area_id"`
}
func (f *EdgeFilter) IsEmpty() bool {
if f == nil {
return true
}
return u.X(f.Isp.String()) == nil && f.AreaID == nil
}
// 错误信息
var (
ErrResourceNotExist = core.NewBizErr("套餐不存在")
ErrResourceInvalid = core.NewBizErr("套餐不可用")
ErrResourceExhausted = core.NewBizErr("套餐已用完")
ErrResourceExpired = core.NewBizErr("套餐已过期")
ErrResourceDailyLimit = core.NewBizErr("套餐每日配额已用完")
ErrEdgesNoAvailable = core.NewBizErr("没有可用的节点")
)

View File

@@ -1,425 +1,113 @@
package services
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/netip"
"platform/pkg/env"
"platform/pkg/u"
"platform/web/core"
e "platform/web/events"
g "platform/web/globals"
"platform/web/globals/orm"
m "platform/web/models"
q "platform/web/queries"
"strings"
"time"
"github.com/hibiken/asynq"
"gorm.io/gen"
"gorm.io/gen/field"
)
type channelBaiyinProvider struct{}
func (s *channelBaiyinProvider) CreateChannels(source netip.Addr, resourceNo string, authWhitelist bool, authPassword bool, count int, filter *EdgeFilter) ([]*m.Channel, error) {
if filter == nil {
return nil, core.NewBizErr("缺少节点过滤条件")
func (s *channelBaiyinProvider) selectProxy(count int) (*m.Proxy, error) {
return selectProxyByType(m.ProxyTypeBaiYin, count)
}
func (s *channelBaiyinProvider) prepareCreate(ctx *channelCreateContext) (*channelCreateResult, error) {
gateway, err := proxyGateway(ctx.Proxy)
if err != nil {
return nil, core.NewServErr("创建代理网关失败", err)
}
prov, city := areaProvinceCity(ctx.Area)
channels := make([]*m.Channel, len(ctx.Ports))
chanConfigs := make([]*g.PortConfigsReq, len(ctx.Ports))
for i, portRef := range ctx.Ports {
channel := newBaseChannel(ctx, portRef.Port())
cfg := &g.PortConfigsReq{
Port: int(portRef.Port()),
Status: true,
AutoEdgeConfig: &g.AutoEdgeConfig{
Province: u.Z(prov),
City: u.Z(city),
Isp: ctx.Filter.Isp.String(),
Count: u.P(1),
},
}
if ctx.AuthWhitelist {
cfg.Whitelist = &ctx.Whitelists
}
if username, password, ok := applyChannelAuth(ctx, channel); ok {
cfg.Userpass = u.P(username + ":" + password)
}
channels[i] = channel
chanConfigs[i] = cfg
}
now := time.Now()
batchNo := ID.GenReadable("bat")
channels := make([]*m.Channel, count)
// 资源锁,防止并发扣减失败导致的端口悬空问题
err := g.Redsync.WithLock(lockChannelCreateKey(resourceNo), func() error {
// 检查并获取套餐与白名单
resource, whitelists, err := ensure(now, source, resourceNo, authWhitelist, count)
if err != nil {
return err
}
user := resource.User
expire := now.Add(resource.Live)
// 选择代理
proxy, gateway, err := selectProxy(count)
if err != nil {
return err
}
// 取用端口
chans, err := selectPorts(proxy.ID, batchNo, count, expire)
if err != nil {
return err
}
// 绑定节点端口
chanConfigs := make([]*g.PortConfigsReq, count)
edgeConfigs := make([]string, 0, count)
for i := range count {
ch := chans[i]
// 通道数据
channels[i] = &m.Channel{
UserID: user.ID,
ResourceID: resource.ID,
BatchNo: batchNo,
ProxyID: proxy.ID,
Host: u.Else(proxy.Host, proxy.IP.String()),
Port: ch.Port(),
FilterISP: filter.Isp,
FilterProv: filter.Prov,
FilterCity: filter.City,
ExpiredAt: expire,
Proxy: proxy,
return &channelCreateResult{
Channels: channels,
applyRemote: func() error {
slog.Debug("提交代理端口配置", "proxy", ctx.Proxy.IP.String(), "total_count", len(chanConfigs))
if err := ensureEdges(ctx.Proxy, gateway, ctx.Area, ctx.Filter.Isp, ctx.Count); err != nil {
slog.Warn("ensureEdges 失败", "err", err)
}
// 通道配置数据
chanConfigs[i] = &g.PortConfigsReq{
Port: int(ch.Port()),
Status: true,
AutoEdgeConfig: &g.AutoEdgeConfig{
Province: u.Z(filter.Prov),
City: u.Z(filter.City),
Isp: filter.Isp.String(),
Count: u.P(1),
},
}
// 白名单模式
if authWhitelist {
channels[i].Whitelists = u.P(strings.Join(whitelists, ","))
chanConfigs[i].Whitelist = &whitelists
}
// 密码模式
if authPassword {
username, password := genPassPair()
channels[i].Username = &username
channels[i].Password = &password
chanConfigs[i].Userpass = u.P(username + ":" + password)
}
}
// 提交配置
slog.Debug("提交代理端口配置", "proxy", proxy.IP.String(), "total_count", len(chanConfigs), "remote_count", len(edgeConfigs))
if env.RunMode == env.RunModeProd {
// 从云端补足节点
err := ensureEdges(proxy, gateway, filter, count)
if err != nil {
slog.Warn("ensureEdges 失败", "err", err) // 不阻止通道创建,继续走后续流程
}
// 启用网关代理通道
if len(chanConfigs) > 0 {
if err := gateway.GatewayPortConfigs(chanConfigs); err != nil {
slog.Warn("提交代理端口配置失败", "error", err.Error())
return core.NewServErr(fmt.Sprintf("配置代理 %s 端口失败", proxy.IP.String()), err)
return core.NewServErr(fmt.Sprintf("配置代理 %s 端口失败", ctx.Proxy.IP.String()), err)
}
}
} else {
for _, item := range chanConfigs {
str, _ := json.Marshal(item)
fmt.Println(string(str))
}
}
// 保存数据
err = q.Q.Transaction(func(q *q.Query) error {
// 更新使用记录
var result gen.ResultInfo
var err error
switch resource.Type {
case m.ResourceTypeShort:
result, err = q.ResourceShort.
Where(
q.ResourceShort.ID.Eq(*resource.ShortId),
q.ResourceShort.Used.Eq(resource.Used),
q.ResourceShort.Daily.Eq(resource.Daily),
).
UpdateSimple(
q.ResourceShort.Used.Add(int32(count)),
q.ResourceShort.Daily.Value(int32(resource.Today+count)),
q.ResourceShort.LastAt.Value(now),
)
case m.ResourceTypeLong:
result, err = q.ResourceLong.
Where(
q.ResourceLong.ID.Eq(*resource.LongId),
q.ResourceLong.Used.Eq(resource.Used),
q.ResourceLong.Daily.Eq(resource.Daily),
).
UpdateSimple(
q.ResourceLong.Used.Add(int32(count)),
q.ResourceLong.Daily.Value(int32(resource.Today+count)),
q.ResourceLong.LastAt.Value(now),
)
default:
return core.NewBizErr("套餐类型不正确,无法更新")
}
if err != nil {
return core.NewServErr("更新套餐使用记录失败", err)
}
if result.RowsAffected == 0 {
return core.NewBizErr("套餐状态已过期")
}
// 保存通道
err = q.Channel.
Omit(field.AssociationFields).
Create(channels...)
if err != nil {
return core.NewServErr("保存通道失败", err)
}
// 保存提取记录
err = q.LogsUserUsage.Create(&m.LogsUserUsage{
UserID: user.ID,
ResourceID: resource.ID,
BatchNo: batchNo,
Count: int32(count),
ISP: u.X(filter.Isp.String()),
Prov: filter.Prov,
City: filter.City,
IP: orm.Inet{Addr: source},
Time: now,
})
if err != nil {
return core.NewServErr("保存用户使用记录失败", err)
}
return nil
})
if err != nil {
return err
}
},
}, nil
}
return nil
})
func (s *channelBaiyinProvider) removeRemote(_ string, batch *usedChanBatch) error {
configs := make([]*g.PortConfigsReq, len(batch.Chans))
for i, ch := range batch.Chans {
configs[i] = &g.PortConfigsReq{
Port: int(ch.Port()),
Edge: &[]string{},
AutoEdgeConfig: &g.AutoEdgeConfig{Count: u.P(0)},
Status: false,
}
}
proxy, err := q.Proxy.Where(q.Proxy.ID.Eq(batch.ProxyID)).Take()
if err != nil {
return nil, err
return core.NewServErr("获取代理数据失败", err)
}
return channels, nil
}
func (s *channelBaiyinProvider) RemoveChannels(batchNo string) error {
return g.Redsync.WithLock(lockChannelRemoveKey(batchNo), func() error {
start := time.Now()
// 获取连接数据
channels, err := q.Channel.Where(q.Channel.BatchNo.Eq(batchNo)).Find()
if err != nil {
return core.NewServErr(fmt.Sprintf("获取通道数据失败batch%s", batchNo), err)
}
if len(channels) == 0 {
slog.Warn(fmt.Sprintf("未找到通道数据batch%s", batchNo))
return nil
}
proxy, err := q.Proxy.Where(q.Proxy.ID.Eq(channels[0].ProxyID)).Take()
if err != nil {
return core.NewServErr(fmt.Sprintf("获取代理数据失败batch%s", batchNo), err)
}
// 检查通道是否存在
chans, err := g.Redis.LRange(context.Background(), usedChansKey(proxy.ID, batchNo), 0, -1).Result()
if err != nil {
return core.NewServErr("查询使用中通道失败", err)
}
if len(chans) == 0 {
slog.Debug("通道为空,跳过清理", "key", usedChansKey(proxy.ID, batchNo))
return nil // 没有使用中通道,已经被清理过了
}
// 准备配置数据
configs := make([]*g.PortConfigsReq, len(chans))
for i, ch := range chans {
ap, err := netip.ParseAddrPort(ch)
if err != nil {
return core.NewServErr(fmt.Sprintf("解析通道数据失败: %s", ch), err)
}
configs[i] = &g.PortConfigsReq{
Port: int(ap.Port()),
Edge: &[]string{},
AutoEdgeConfig: &g.AutoEdgeConfig{Count: u.P(0)},
Status: false,
}
}
// 提交配置
if env.RunMode == env.RunModeProd {
gateway, err := proxyGateway(proxy)
if err != nil {
return core.NewServErr("创建代理网关失败", err)
}
if err = gateway.GatewayPortConfigs(configs); err != nil {
return core.NewServErr(fmt.Sprintf("清空代理 %s 端口配置失败", proxy.IP.String()), err)
}
} else {
for _, item := range configs {
str, _ := json.Marshal(item)
fmt.Println(string(str))
}
}
// 释放端口
err = freeChans(proxy.ID, batchNo)
if err != nil {
return err
}
slog.Debug("清除代理端口配置", "proxy", proxy.ID, "batch", batchNo, "duration", time.Since(start).String())
return nil
})
}
// ClearExpiredChannels 清理指定代理的过期通道,并返回清理数量(现在理论上不会有需要手动批量清理的通道,未来可以废弃)
func (s *channelBaiyinProvider) ClearExpiredChannels(proxyId int32) (int, error) {
now := time.Now()
// 获取未清理通道
keys, err := g.Redis.Keys(context.Background(), usedChansKey(proxyId, "*")).Result()
if err != nil {
return 0, core.NewServErr("查询使用中通道失败", err)
}
if len(keys) == 0 {
return 0, nil
}
batchList := make([]string, len(keys))
batchSet := make(map[string]struct{}, len(keys))
for i, key := range keys {
parts := strings.Split(key, ":")
if len(parts) != 4 {
return 0, core.NewServErr(fmt.Sprintf("使用中通道键格式错误: %s", key), nil)
}
batchList[i] = parts[3]
batchSet[parts[3]] = struct{}{}
}
// 排除未过期通道
var batchQueried []struct{ BatchNo string }
err = q.Channel.
Select(q.Channel.BatchNo).
Where(
q.Channel.BatchNo.In(batchList...),
q.Channel.ExpiredAt.Gte(now.UTC()),
).
Group(q.Channel.BatchNo).
Scan(&batchQueried)
if err != nil {
return 0, core.NewServErr("查询过期通道失败", err)
}
for _, batch := range batchQueried {
delete(batchSet, batch.BatchNo)
}
// 清理过期通道
slog.Info("批量清理过期通道", "count", len(batchSet))
for batchNo, _ := range batchSet {
err := s.RemoveChannels(batchNo)
if err != nil {
slog.Error("清理过期通道失败", "batch", batchNo, "error", err)
}
}
return len(batchSet), nil
}
func lockChannelCreateKey(resourceNo string) string {
return fmt.Sprintf("platform:channel:create:%s", resourceNo)
}
func lockChannelRemoveKey(bid string) string {
return fmt.Sprintf("platform:batch:remove_expired:%s", bid)
}
func selectProxy(count int) (*m.Proxy, g.GatewayClient, error) {
// 获取在线节点
proxies, err := q.Proxy.Where(
q.Proxy.Type.Eq(int(m.ProxyTypeBaiYin)),
q.Proxy.Status.Eq(int(m.ProxyStatusOnline)),
).Find()
if err != nil {
return nil, nil, core.NewBizErr("获取可用代理失败", err)
}
if len(proxies) == 0 {
return nil, nil, core.NewBizErr("无可用代理")
}
proxyIDs := make([]int32, 0, len(proxies))
proxyMap := make(map[int32]*m.Proxy, len(proxies))
for _, item := range proxies {
proxyIDs = append(proxyIDs, item.ID)
proxyMap[item.ID] = item
}
// 获取最空闲节点
maxId := int32(0)
maxCount := -1
for _, id := range proxyIDs {
idCount, err := g.Redis.SCard(context.Background(), freeChansKey(id)).Result()
if err != nil {
return nil, nil, fmt.Errorf("查询可用通道数量失败: %w", err)
}
if idCount > int64(maxCount) {
maxCount = int(idCount)
maxId = id
}
}
if maxCount < count {
return nil, nil, core.NewBizErr("无可用代理")
}
proxy := proxyMap[maxId]
gateway, err := proxyGateway(proxy)
if err != nil {
return nil, nil, core.NewServErr("创建代理网关失败", err)
return core.NewServErr("创建代理网关失败", err)
}
return proxy, gateway, nil
}
func selectPorts(proxyId int32, batchNo string, count int, expire time.Time) ([]netip.AddrPort, error) {
chans, err := lockChans(proxyId, batchNo, count)
if err != nil {
return nil, core.NewBizErr("无可用通道,请稍后再试", err)
if err = gateway.GatewayPortConfigs(configs); err != nil {
return core.NewServErr(fmt.Sprintf("清空代理 %s 端口配置失败", proxy.IP.String()), err)
}
_, err = g.Asynq.Enqueue(
e.NewRemoveChannel(batchNo),
asynq.ProcessAt(expire),
)
if err != nil {
return nil, core.NewServErr("注册异步关闭通道任务失败", err)
}
return chans, nil
return nil
}
// ensureEdges 检查本地节点是否足够,如果不足从云端连入
// 本地节点通过 Assigned = false 排除已分配节点
// 云端节点通过 NoRepeat = true 排除已分配节点
func ensureEdges(proxy *m.Proxy, gateway g.GatewayClient, filter *EdgeFilter, count int) error {
if filter.IsEmpty() {
func ensureEdges(proxy *m.Proxy, gateway g.GatewayClient, area *m.Area, isp *m.EdgeISP, count int) error {
prov, city := areaProvinceCity(area)
if prov == nil && city == nil && u.X(isp.String()) == nil {
return nil // 没有过滤条件,直接返回空,避免无意义的查询
}
// 先查本地
localEdges, err := gateway.GatewayEdge(&g.GatewayEdgeReq{
Province: filter.Prov,
City: filter.City,
Isp: u.X(filter.Isp.String()),
Province: prov,
City: city,
Isp: u.X(isp.String()),
Limit: &count,
Assigned: u.P(false),
})
@@ -433,9 +121,9 @@ func ensureEdges(proxy *m.Proxy, gateway g.GatewayClient, filter *EdgeFilter, co
// 再查云端
remaining := count - len(localEdges)
cloudEdges, err := g.Cloud.CloudEdges(&g.CloudEdgesReq{
Province: filter.Prov,
City: filter.City,
Isp: u.X(filter.Isp.String()),
Province: prov,
City: city,
Isp: u.X(isp.String()),
Limit: &remaining,
NoRepeat: u.P(true),
ActiveTime: u.P(3600),

View File

@@ -0,0 +1,211 @@
package services
import (
"fmt"
"platform/pkg/env"
"platform/pkg/u"
"platform/web/core"
g "platform/web/globals"
m "platform/web/models"
q "platform/web/queries"
"strings"
"gorm.io/gen/field"
)
type channelGostProvider struct{}
func (s *channelGostProvider) prepareCreate(ctx *channelCreateContext) (*channelCreateResult, error) {
edges, err := s.selectEdge(ctx.Filter, ctx.Area, ctx.Count)
if err != nil {
return nil, err
}
client, err := proxyGost(ctx.Proxy)
if err != nil {
return nil, err
}
admissions := make([]*g.GostAdmissionConfig, 0, ctx.Count)
authers := make([]*g.GostAutherConfig, 0, ctx.Count)
services := make([]*g.GostServiceConfig, len(ctx.Ports))
channels := make([]*m.Channel, len(ctx.Ports))
for i, portRef := range ctx.Ports {
edge := edges[i]
port := portRef.Port()
serviceName := gostServiceName(ctx.BatchNo, port)
channel := newBaseChannel(ctx, port)
channel.EdgeID = u.P(edge.ID)
channel.EdgeRef = u.P(edge.Mac)
channel.IP = u.P(edge.IP)
service := &g.GostServiceConfig{
Name: serviceName,
Addr: fmt.Sprintf(":%d", port),
Handler: g.GostHandlerConfig{
Type: "auto",
Chain: edge.Mac,
},
Listener: g.GostListenerConfig{
Type: "tcp",
},
Recorders: []g.GostRecorderConfig{
{Name: "record-file", Record: "recorder.service.handler"},
},
}
if ctx.AuthWhitelist {
service.Admission = gostAdmissionName(ctx.BatchNo, port)
admissions = append(admissions, &g.GostAdmissionConfig{
Name: service.Admission,
Whitelist: true,
Matchers: ctx.Whitelists,
})
}
if username, password, ok := applyChannelAuth(ctx, channel); ok {
service.Handler.Auther = gostAutherName(ctx.BatchNo, port)
authers = append(authers, &g.GostAutherConfig{
Name: service.Handler.Auther,
Auths: []g.GostAuthConfig{{
Username: username,
Password: password,
}},
})
}
services[i] = service
channels[i] = channel
}
return &channelCreateResult{
Channels: channels,
applyRemote: func() error {
for _, admission := range admissions {
if err := client.CreateAdmission(admission); err != nil {
return core.NewServErr(fmt.Sprintf("创建 GOST admission 失败: %s", admission.Name), err)
}
}
for _, auther := range authers {
if err := client.CreateAuther(auther); err != nil {
return core.NewServErr(fmt.Sprintf("创建 GOST auther 失败: %s", auther.Name), err)
}
}
for _, service := range services {
if err := client.CreateService(service); err != nil {
return core.NewServErr(fmt.Sprintf("创建 GOST service 失败: %s", service.Name), err)
}
}
return nil
},
}, nil
}
func (s *channelGostProvider) removeRemote(batchNo string, batch *usedChanBatch) error {
proxy, err := q.Proxy.Where(q.Proxy.ID.Eq(batch.ProxyID)).Take()
if err != nil {
return core.NewServErr("获取代理数据失败", err)
}
client, err := proxyGost(proxy)
if err != nil {
return core.NewServErr("创建 GOST 客户端失败", err)
}
var deleteErrs []error
for _, ch := range batch.Chans {
port := ch.Port()
serviceName := gostServiceName(batchNo, port)
deleteErrs = append(deleteErrs, deleteGostResource("service", serviceName, func() error {
return client.DeleteService(serviceName)
}))
autherName := gostAutherName(batchNo, port)
deleteErrs = append(deleteErrs, deleteGostResource("auther", autherName, func() error {
return client.DeleteAuther(autherName)
}))
admissionName := gostAdmissionName(batchNo, port)
deleteErrs = append(deleteErrs, deleteGostResource("admission", admissionName, func() error {
return client.DeleteAdmission(admissionName)
}))
}
return u.CombineErrors(deleteErrs)
}
func (s *channelGostProvider) selectProxy(count int) (*m.Proxy, error) {
return selectProxyByType(m.ProxyTypeGost, count)
}
func (s *channelGostProvider) selectEdge(filter *EdgeFilter, area *m.Area, count int) ([]*m.Edge, error) {
if filter == nil {
filter = &EdgeFilter{}
}
do := q.Edge.Where(
q.Edge.Type.Eq(int(m.EdgeTypeGostChain)),
q.Edge.Status.Eq(int(m.EdgeStatusNormal)),
)
if filter.Isp != nil {
do = do.Where(q.Edge.ISP.In(int(m.EdgeISPUnknown), int(*filter.Isp)))
}
if area != nil {
switch area.Level {
case m.AreaLevelProvince:
edgeArea := q.Area.As("EdgeArea")
do = do.
Where(edgeArea.ParentID.Eq(area.ID)).
Join(edgeArea, edgeArea.ID.EqCol(q.Edge.AreaID))
case m.AreaLevelCity:
do = do.Where(q.Edge.AreaID.Eq(area.ID))
default:
return nil, core.NewBizErr("地区层级不支持")
}
}
edges, err := do.
Order(field.NewUnsafeFieldRaw("random()")).
Limit(count).
Find()
if err != nil {
return nil, core.NewBizErr("查询可用节点失败", err)
}
if len(edges) == 0 {
return nil, core.NewBizErr("地区可用节点数量不足")
}
result := make([]*m.Edge, count)
for i := range count {
result[i] = edges[i%len(edges)]
}
return result, nil
}
func proxyGost(proxy *m.Proxy) (g.GostClient, error) {
secret := strings.Split(u.Z(proxy.Secret), ":")
if len(secret) != 2 {
return nil, core.NewServErr(fmt.Sprintf("代理 %s 密钥格式错误", proxy.IP.String()), nil)
}
host := u.Else(proxy.Host, proxy.IP.String())
return g.NewGost(host, env.GostApiPort, env.GostApiPathPrefix, secret[0], secret[1]), nil
}
func deleteGostResource(kind string, name string, deleteFn func() error) error {
if err := deleteFn(); err != nil && !g.IsGostNotFound(err) {
return core.NewServErr(fmt.Sprintf("删除 GOST %s 配置失败: %s", kind, name), err)
}
return nil
}
func gostServiceName(batchNo string, port uint16) string {
return fmt.Sprintf("gost-svc-%s-%d", batchNo, port)
}
func gostAutherName(batchNo string, port uint16) string {
return fmt.Sprintf("gost-auther-%s-%d", batchNo, port)
}
func gostAdmissionName(batchNo string, port uint16) string {
return fmt.Sprintf("gost-adm-%s-%d", batchNo, port)
}

View File

@@ -1,52 +0,0 @@
package services
import (
"platform/pkg/u"
m "platform/web/models"
q "platform/web/queries"
)
var Edge = &edgeService{}
type edgeService struct{}
func (s *edgeService) AllEdges(count int, filter EdgeFilter) ([]*m.Edge, error) {
do := q.Edge.Where()
if filter.Prov != nil {
do = do.Where(q.Edge.Prov.Eq(*filter.Prov))
}
if filter.City != nil {
do = do.Where(q.Edge.City.Eq(*filter.City))
}
if filter.Isp != nil {
do = do.Where(q.Edge.ISP.Eq(int(*filter.Isp)))
}
if count > 0 {
do = do.Limit(count)
}
edges, err := q.Edge.Where(do).Find()
if err != nil {
return nil, err
}
return edges, nil
}
type EdgeFilter struct {
Isp *m.EdgeISP `json:"isp"`
Prov *string `json:"prov"`
City *string `json:"city"`
}
func (f *EdgeFilter) IsEmpty() bool {
if f == nil {
return true
}
if f.Isp.String() == "" || u.Z(f.Prov) != "" || u.Z(f.City) != "" {
return false
}
return false
}

View File

@@ -2,6 +2,7 @@ package services
import (
"context"
"errors"
"fmt"
"net/netip"
"platform/pkg/u"
@@ -14,6 +15,7 @@ import (
"time"
"gorm.io/gen/field"
"gorm.io/gorm"
)
var Proxy = &proxyService{}
@@ -23,11 +25,20 @@ type proxyService struct{}
func hasUsedChans(proxyID int32) (bool, error) {
ctx := context.Background()
pattern := usedChansKey(proxyID, "*")
keys, _, err := g.Redis.Scan(ctx, 0, pattern, 1).Result()
if err != nil {
return false, err
var cursor uint64
for {
keys, next, err := g.Redis.Scan(ctx, cursor, pattern, 100).Result()
if err != nil {
return false, err
}
if len(keys) > 0 {
return true, nil
}
if next == 0 {
return false, nil
}
cursor = next
}
return len(keys) > 0, nil
}
func rebuildFreeChans(proxyID int32, addr netip.Addr) error {
@@ -64,6 +75,7 @@ type CreateProxy struct {
Mac string `json:"mac" validate:"required"`
IP string `json:"ip" validate:"required"`
Host *string `json:"host"`
Port *int `json:"port"`
Secret *string `json:"secret"`
Type *m.ProxyType `json:"type"`
Status *m.ProxyStatus `json:"status"`
@@ -80,6 +92,7 @@ func (s *proxyService) Create(create *CreateProxy) error {
Mac: create.Mac,
IP: orm.Inet{Addr: addr},
Host: create.Host,
Port: create.Port,
Secret: create.Secret,
Type: u.Else(create.Type, m.ProxyTypeSelfHosted),
Status: u.Else(create.Status, m.ProxyStatusOffline),
@@ -99,6 +112,7 @@ type UpdateProxy struct {
Mac *string `json:"mac"`
IP *string `json:"ip"`
Host *string `json:"host"`
Port *int `json:"port"`
Secret *string `json:"secret"`
}
@@ -121,6 +135,9 @@ func (s *proxyService) Update(update *UpdateProxy) error {
if update.Host != nil {
simples = append(simples, q.Proxy.Host.Value(*update.Host))
}
if update.Port != nil {
simples = append(simples, q.Proxy.Port.Value(*update.Port))
}
if update.Secret != nil {
hasSideEffect = true
simples = append(simples, q.Proxy.Secret.Value(*update.Secret))
@@ -155,6 +172,117 @@ func (s *proxyService) Update(update *UpdateProxy) error {
return nil
}
func (s *proxyService) SyncPorts(id int32) error {
proxy, err := findOfflineProxy(id)
if err != nil {
return err
}
used, err := hasUsedChans(id)
if err != nil {
return core.NewServErr("检查代理通道状态失败", err)
}
if used {
return core.NewBizErr("代理存在未关闭通道,禁止重建端口池")
}
return rebuildFreeChans(id, proxy.IP.Addr)
}
func (s *proxyService) SyncChains(id int32) error {
proxy, err := findOfflineProxy(id)
if err != nil {
return err
}
if proxy.Type != m.ProxyTypeGost {
return core.NewBizErr("仅 GOST 代理支持重建代理链")
}
chains, err := buildGostChainsFromEdges()
if err != nil {
return err
}
client, err := proxyGost(proxy)
if err != nil {
return core.NewServErr("创建 GOST 客户端失败", err)
}
oldChains, err := client.ListChains()
if err != nil {
return core.NewServErr("查询 GOST chains 失败", err)
}
for _, chain := range oldChains {
if err := client.DeleteChain(chain.Name); err != nil {
return core.NewServErr(fmt.Sprintf("删除 GOST chain 失败: %s", chain.Name), err)
}
}
for _, chain := range chains {
if err := client.CreateChain(chain); err != nil {
return core.NewServErr(fmt.Sprintf("创建 GOST chain 失败: %s", chain.Name), err)
}
}
if err := client.SaveConfig(); err != nil {
return core.NewServErr("保存 GOST 配置失败", err)
}
return nil
}
func findOfflineProxy(id int32) (*m.Proxy, error) {
proxy, err := q.Proxy.Where(q.Proxy.ID.Eq(id)).Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, core.NewBizErr("代理不存在")
}
if err != nil {
return nil, core.NewServErr("获取代理数据失败", err)
}
if proxy.Status != m.ProxyStatusOffline {
return nil, core.NewBizErr("代理未下线,禁止同步")
}
return proxy, nil
}
func buildGostChainsFromEdges() ([]*g.GostChainConfig, error) {
edges, err := q.Edge.
Where(q.Edge.Type.Eq(int(m.EdgeTypeGostChain))).
Order(q.Edge.ID).
Find()
if err != nil {
return nil, core.NewServErr("查询 GOST edge 数据失败", err)
}
chains := make([]*g.GostChainConfig, len(edges))
for i, edge := range edges {
if strings.TrimSpace(edge.Mac) == "" {
return nil, core.NewBizErr(fmt.Sprintf("GOST edge %d chain 名称为空", edge.ID))
}
if !edge.IP.Addr.IsValid() {
return nil, core.NewBizErr(fmt.Sprintf("GOST edge %s IP 无效", edge.Mac))
}
if edge.Port == nil || *edge.Port == 0 {
return nil, core.NewBizErr(fmt.Sprintf("GOST edge %s 端口为空", edge.Mac))
}
chains[i] = &g.GostChainConfig{
Name: edge.Mac,
Hops: []g.GostHopConfig{{
Nodes: []g.GostNodeConfig{{
Addr: netip.AddrPortFrom(edge.IP.Addr, *edge.Port).String(),
Connector: g.GostConnectorConfig{
Type: "socks5",
},
Dialer: g.GostDialerConfig{
Type: "tcp",
},
}},
}},
}
}
return chains, nil
}
func (s *proxyService) Remove(id int32) error {
used, err := hasUsedChans(id)
if err != nil {

View File

@@ -53,7 +53,6 @@ func RunApp(pCtx context.Context) error {
var fs embed.FS
func RunWeb(ctx context.Context) error {
fiber := fiber.New(fiber.Config{
ProxyHeader: fiber.HeaderXForwardedFor,
ErrorHandler: ErrorHandler,