├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── ci.yaml │ ├── main.yaml │ ├── push-images.yaml │ ├── review.yaml │ └── test.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── clash.yaml ├── config.example.yaml ├── controller ├── admin │ └── enter.go ├── enter.go └── user │ ├── base.go │ ├── enter.go │ └── tg.go ├── dao ├── common.go ├── common_test.go ├── enter.go ├── system_info.go └── users.go ├── global └── global.go ├── go.mod ├── go.sum ├── initialize ├── global │ ├── enter.go │ ├── log.go │ └── tz.go ├── server.go └── system │ ├── db.go │ ├── enter.go │ ├── tg.go │ └── timer.go ├── log └── .gitkeep ├── main.go ├── main_test.go ├── middleware ├── other.go └── validator.go ├── model ├── common │ ├── default.go │ ├── request.go │ └── response.go ├── config │ ├── child.go │ └── default.go └── db │ ├── default.go │ ├── systemInfo.go │ └── users.go ├── router └── router.go ├── service ├── admin │ └── enter.go ├── enter.go └── user │ ├── enter.go │ ├── index.go │ ├── index_test.go │ ├── tg.go │ └── validator.go ├── static ├── 404.html ├── favicon.ico └── robots.txt ├── task ├── clear.go └── expiry.go └── utils ├── tool.go └── tool_test.go /.editorconfig: -------------------------------------------------------------------------------- 1 | # 这文件作用: 统一团队的编码风格,需安装插件实现 2 | # https://editorconfig.org/ 3 | 4 | # 通常建议项目最顶层的配置文件设置该值,这样才会停止继续向上查找.editorconfig文件;查找的.editorconfig文件是从顶层开始读取的,类似变量作用域的效果,内部的.editorconfig文件属性优先级更高 5 | root = true 6 | 7 | [*] 8 | # 编码格式.支持latin1/utf-8/utf-8-bom/utf-16be/utf-16le,不建议使用uft-8-bom. 9 | charset = utf-8 10 | # 定义换行符 [lf | cr | crlf] 11 | end_of_line = lf 12 | # 设置整数用于指定替代tab的列数.默认值就是indent_size的值,一般无需指定 13 | tab_width = 4 14 | # 缩进的大小 15 | indent_size = 4 16 | # 缩进的类型 [space | tab] 17 | indent_style = space 18 | # 文件是否以一个空白行结尾 [true | false] 19 | insert_final_newline = true 20 | # 是否除去换行行首的任意空白字符 21 | trim_trailing_whitespace = true 22 | # 一行限制字数,防止单行字数过多影响阅读 23 | # max_line_length = 100 24 | 25 | [*.{md,markdown}] 26 | trim_trailing_whitespace = false 27 | 28 | [*.{css,js,html,xml,xhtml,vue,json,yml,yaml,toml}] 29 | tab_width = 2 30 | indent_size = 2 31 | 32 | [*.go] 33 | indent_style = tab 34 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # github显示的默认语言 2 | 3 | *.sql linguist-language=Go 4 | 5 | 6 | # 以下设置作用: 制定换行符规则 7 | 8 | # 文件的行尾自动转换.如果是文本文件,则在文件入Git库时,行尾自动转换为LF.如果已经在入Git库中的文件的行尾是GRLF,则文件在入Git库时,不再转换为LF. 9 | * text=auto 10 | 11 | # 对于.txt文件,标记为文本文件,并进行行尾规范化. 12 | *.txt text 13 | 14 | # 对于图片文件,标记为非文本文件 15 | *.jpg -text 16 | *.jpeg -text 17 | *.png -text 18 | *.svg -text 19 | *.ico -text 20 | 21 | # 视为二进制文件,不进行比较(git diff) 22 | *.sql binary 23 | *.db binary 24 | *.zip binary 25 | *.tar binary 26 | *.rar binary 27 | *.gz binary 28 | *.xlsx binary 29 | *.xls binary 30 | *.docx binary 31 | *.doc binary 32 | *.pdf binary 33 | *.pbxroj binary 34 | 35 | # 对于.vcproj文件,标记为文本文件,在文件入Git库时进行规范化,行尾转换为LF.在检测到出工作目录时,行尾自动转换为GRLF. 36 | *.vcproj text eol=crlf 37 | 38 | # 标记为文本文件,在文件入Git库时进行规范化,即行尾为LF.在检出到工作目录时,行尾也不会转换为CRLF(即保持LF). 39 | *.sh text eol=lf 40 | *.go text eol=lf 41 | *.md text eol=lf 42 | *.markdown text eol=lf 43 | *.py text eol=lf 44 | *.js text eol=lf 45 | *.jsx text eol=lf 46 | *.toml text eol=lf 47 | *.json text eol=lf 48 | *.php text eol=lf 49 | *.html text eol=lf 50 | *.css text eol=lf 51 | *.vue text eol=lf 52 | *.xml text eol=lf 53 | *.xhtml text eol=lf 54 | *.yml text eol=lf 55 | *.yaml text eol=lf 56 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | workflow_dispatch: #github页面手动触发 4 | workflow_call: #允许其他workflow调用 5 | push: #push即触发 6 | branches: #只允许分支(branches)类型的push, 否则遇到tags类型的push,当前workflow也会生效 7 | - '*' 8 | paths-ignore: #不作为触发的文件 9 | - '.*' 10 | - 'LICENSE' 11 | - 'Dockerfile' 12 | pull_request: 13 | branches: 14 | - '*' 15 | paths-ignore: 16 | - '.*' 17 | - 'LICENSE' 18 | - 'README.md' 19 | 20 | jobs: 21 | 22 | test: 23 | uses: ./.github/workflows/test.yaml 24 | 25 | review: 26 | needs: test 27 | uses: ./.github/workflows/review.yaml 28 | secrets: inherit #传递所有secrets, 被调用的不需要接收 29 | 30 | 31 | dependency-review: 32 | if: github.base_ref != '' || github.head_ref != '' 33 | needs: test 34 | runs-on: ubuntu-latest 35 | permissions: 36 | contents: read 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: actions/dependency-review-action@v3 40 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | workflow_dispatch: #github页面手动触发 5 | push: #push即触发 6 | tags: 7 | - "v*.*" 8 | jobs: 9 | 10 | test: 11 | uses: ./.github/workflows/test.yaml 12 | 13 | metadata: 14 | runs-on: ubuntu-latest 15 | outputs: 16 | image-tags: ${{ steps.extract-metadata.outputs.tags }} 17 | image-labels: ${{ steps.extract-metadata.outputs.labels }} 18 | steps: 19 | - id: extract-repo-name 20 | run: | 21 | repo_name="" 22 | if [ -n "$GITHUB_REPOSITORY" ]; then 23 | repo_name=${GITHUB_REPOSITORY#*/} 24 | repo_name=${repo_name#*/} 25 | repo_name=${repo_name#*docker-} 26 | fi 27 | echo "repo-name=${repo_name}" >> $GITHUB_OUTPUT 28 | 29 | - id: extract-metadata 30 | if: steps.extract-repo-name.outputs.repo-name && vars.DOCKERHUB_USERNAME 31 | uses: docker/metadata-action@v5 32 | with: 33 | images: | 34 | ${{ vars.DOCKERHUB_USERNAME }}/${{ steps.extract-repo-name.outputs.repo-name }} 35 | ghcr.io/${{ github.repository_owner }}/${{ steps.extract-repo-name.outputs.repo-name }} 36 | #设置镜像tag 37 | tags: | 38 | type=schedule 39 | type=ref,event=branch 40 | type=ref,event=pr 41 | type=semver,pattern={{version}} 42 | type=semver,pattern={{major}}.{{minor}} 43 | type=semver,pattern={{major}} 44 | type=sha 45 | 46 | push-images: 47 | needs: [test, metadata] 48 | if: needs.metadata.outputs.image-tags 49 | uses: ./.github/workflows/push-images.yaml 50 | secrets: inherit #传递所有secrets, 被调用的不需要接收 51 | with: 52 | image-tags: ${{ needs.metadata.outputs.image-tags }} 53 | image-labels: ${{ needs.metadata.outputs.image-labels }} 54 | dockerhub-username: ${{ vars.DOCKERHUB_USERNAME }} 55 | -------------------------------------------------------------------------------- /.github/workflows/push-images.yaml: -------------------------------------------------------------------------------- 1 | name: push-images 2 | on: 3 | workflow_dispatch: #github页面手动触发 4 | workflow_call: #允许其他workflow调用 5 | secrets: 6 | DOCKERHUB_TOKEN: 7 | description: 'dockerHub用户凭证' 8 | required: false 9 | inputs: 10 | image-tags: 11 | description: '镜像tags' 12 | required: true 13 | type: string 14 | image-labels: 15 | description: '镜像labels' 16 | required: false 17 | type: string 18 | dockerhub-username: 19 | description: 'dockerHub用户名' 20 | required: false 21 | default: '' 22 | type: string 23 | platforms: 24 | description: '构建镜像平台' 25 | default: 'linux/amd64,linux/arm64' 26 | required: false 27 | type: string 28 | 29 | jobs: 30 | 31 | push-images: 32 | name: Push Docker image to multiple registries 33 | runs-on: ubuntu-latest 34 | permissions: 35 | packages: write 36 | contents: read 37 | steps: 38 | - name: Check out the repo 39 | uses: actions/checkout@v4 40 | 41 | - name: Set up QEMU 42 | uses: docker/setup-qemu-action@v3 43 | 44 | - name: Set up Docker Buildx 45 | uses: docker/setup-buildx-action@v3 46 | 47 | - name: login DockerHub 48 | if: inputs.dockerhub-username #这无法判断secrets 49 | uses: docker/login-action@v3 50 | with: 51 | username: ${{ inputs.dockerhub-username }} 52 | password: ${{ secrets.DOCKERHUB_TOKEN }} 53 | 54 | - name: login ghcr 55 | uses: docker/login-action@v3 56 | with: 57 | registry: ghcr.io 58 | username: ${{ github.actor }} 59 | password: ${{ secrets.GITHUB_TOKEN }} 60 | 61 | 62 | - name: Build and push Docker images 63 | uses: docker/build-push-action@v6 64 | with: 65 | context: . 66 | file: ./Dockerfile 67 | platforms: ${{ inputs.platforms }} 68 | push: true 69 | tags: ${{ inputs.image-tags }} 70 | labels: ${{ inputs.image-labels }} 71 | -------------------------------------------------------------------------------- /.github/workflows/review.yaml: -------------------------------------------------------------------------------- 1 | name: review 2 | on: 3 | workflow_dispatch: #github页面手动触发 4 | workflow_call: #允许其他workflow调用 5 | secrets: 6 | CODACY_PROJECT_TOKEN: 7 | description: 'CODACY用户凭证' 8 | required: true 9 | 10 | jobs: 11 | 12 | codacy-review: 13 | runs-on: ubuntu-latest #虚拟环境(github提供) 14 | env: 15 | TZ: Asia/Shanghai 16 | steps: 17 | - uses: actions/checkout@v4 18 | # 运行Codacy,可使用.codacy.yml配置 19 | - name: Codacy Review 20 | uses: codacy/codacy-analysis-cli-action@master 21 | with: 22 | # tool: PHP_CodeSniffer,PHP Mess Detector,PHPCPD,PHP Depend,phpmd,phpcs #参考: https://docs.codacy.com/getting-started/supported-languages-and-tools/ 23 | project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} 24 | upload: true #上传"代码质量"报告到Codacy(因为本地审查代码,要关闭远程Codacy的审查功能(Repository analysis开启)) 25 | format: sarif 26 | output: ../results.sarif #要求干净的环境,把文件放在项目外,否则会报错 27 | gh-code-scanning-compat: true #兼容Github的报错等级 28 | verbose: true #列出详情 29 | max-allowed-issues: 2147483647 #允许最大的"问题数" 30 | # 对接Github的Security菜单 31 | - name: Upload-github-sarif-reporter 32 | uses: github/codeql-action/upload-sarif@v3 33 | with: 34 | sarif_file: ../results.sarif 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | workflow_dispatch: #github页面手动触发 4 | workflow_call: #允许其他workflow调用 5 | 6 | jobs: 7 | 8 | test: 9 | runs-on: ubuntu-latest 10 | env: 11 | TZ: Asia/Shanghai 12 | services: #容器配置 13 | mysql: 14 | image: mysql:8.0 15 | env: 16 | MYSQL_ALLOW_EMPTY_PASSWORD: true 17 | ports: 18 | - 3306:3306 19 | options: >- 20 | --health-cmd="mysqladmin ping" 21 | --health-interval 10s 22 | --health-timeout 5s 23 | --health-retries 5 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | # - name: save-data 28 | # run: sudo mysql -uroot -h 127.0.0.1 < dao/db.sql 29 | 30 | - name: add-configFile 31 | run: sudo cp config.example.yaml config.yaml 32 | 33 | - name: Set up Go 34 | uses: actions/setup-go@v5 35 | with: 36 | go-version-file: 'go.mod' 37 | 38 | - name: Build 39 | run: go build -v ./... 40 | 41 | - name: Test 42 | run: go test -v ./... 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore 2 | !.gitkeep 3 | !.htaccess 4 | 5 | # ide 6 | .idea/ 7 | .project/ 8 | .vscode/ 9 | .vs/ 10 | !.vscode/settings.json 11 | !.vscode/tasks.json 12 | !.vscode/launch.json 13 | !.vscode/extensions.json 14 | *.tmlanguage.cache 15 | *.tmPreferences.cache 16 | *.stTheme.cache 17 | *.sublime-workspace 18 | *.sublime-project 19 | *.sw[a-p] 20 | modules.xml 21 | vendor/ 22 | _vendor/ 23 | *.ipr 24 | *.iml 25 | ! 26 | 27 | #js 28 | node_modules/ 29 | yarn.lock 30 | .yarn-integrity 31 | package-lock.json 32 | .npm 33 | 34 | #cache 35 | runtime/ 36 | cache/ 37 | temp/ 38 | 39 | #log 40 | logs/* 41 | *.log 42 | npm-debug.log* 43 | yarn-debug.log* 44 | yarn-error.log* 45 | 46 | #Build 47 | dist/ 48 | build/ 49 | 50 | #https 51 | .user.ini 52 | .well-known/* 53 | 54 | #system 55 | .DocumentRevisions-V100 56 | .fseventsd 57 | .Spotlight-V100 58 | .TemporaryItems 59 | .Trashes 60 | .VolumeIcon.icns 61 | .com.apple.timemachine.donotpresent 62 | ._* 63 | $RECYCLE.BIN/ 64 | Desktop.ini 65 | Thumbs.db 66 | ehthumbs.db 67 | ehthumbs_vista.db 68 | *.DS_Store 69 | .serverless/ 70 | /jspm_packages 71 | /bower_components 72 | .eslintcache 73 | .env 74 | /config.yaml 75 | /*.db 76 | 77 | 78 | /worktree*/ 79 | .phpunit.result.cache 80 | /tests/report/* 81 | tmp/ 82 | /server 83 | /wechat/ 84 | !.gitkeep 85 | static/*/ 86 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ##编译 2 | FROM golang:1.24 AS builder 3 | WORKDIR /app 4 | ARG TARGETARCH 5 | ENV GO111MODULE=on 6 | # ENV GOPROXY="https://goproxy.cn,direct" 7 | COPY go.mod go.sum ./ 8 | RUN go mod download 9 | COPY . . 10 | #go-sqlite3需要cgo编译; 且使用完全静态编译, 否则需依赖外部安装的glibc 11 | RUN CGO_ENABLED=1 GOOS=linux GOARCH=$TARGETARCH go build -ldflags "-s -w --extldflags '-static -fpic'" -o server . && \ 12 | mv config.example.yaml clash.yaml server /app/static 13 | 14 | 15 | ##打包镜像 16 | FROM alpine:latest 17 | LABEL org.opencontainers.image.vendor="忐忑" 18 | LABEL org.opencontainers.image.authors="1174865138@qq.com" 19 | LABEL org.opencontainers.image.description="用于管理翻墙系统用户和订阅" 20 | LABEL org.opencontainers.image.source="https://github.com/twbworld/proxy" 21 | WORKDIR /app 22 | COPY --from=builder /app/static/ static/ 23 | RUN set -xe && \ 24 | mv static/config.example.yaml config.yaml && \ 25 | mv static/clash.yaml static/server . && \ 26 | chmod +x server && \ 27 | # sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \ 28 | apk update && \ 29 | apk add -U --no-cache tzdata ca-certificates && \ 30 | apk cache clean && \ 31 | rm -rf /var/cache/apk/* 32 | # EXPOSE 80 33 | ENTRYPOINT ["./server"] 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 忐忑 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | **Proxy** 3 | =========== 4 | [![](https://github.com/twbworld/proxy/workflows/ci/badge.svg?branch=main)](https://github.com/twbworld/proxy/actions) 5 | [![](https://img.shields.io/github/tag/twbworld/proxy?logo=github)](https://github.com/twbworld/proxy) 6 | ![](https://img.shields.io/badge/language-golang-cyan) 7 | [![](https://img.shields.io/github/license/twbworld/proxy)](https://github.com/twbworld/proxy/blob/main/LICENSE) 8 | 9 | ## 简介 10 | **翻墙订阅链接服务 和 用户管理** 11 | 12 | ### 本项目有两个作用: 13 | 14 | 1. 用户管理(增删改查) 15 | > 程序作为中间人, 通过与`telegram-bot`进行交互,实现对接用户数据库, 进行用户管理 16 | > 如喜欢用文件而不是`telegram-bot`进行用户管理, 请使用`v0`版本 17 | > 如喜欢用shell进行用户管理, 建议出门左转使用[Jrohy/trojan](https://github.com/Jrohy/trojan)哟 18 | 2. 程序返回客户端可识别的翻墙配置, 即订阅功能 19 | > 访问含用户名的特定订阅链接, 程序返回`v2ray`和`clash`等客户端可以识别的base64码或`clash`配置文件; 在客户端上配置订阅链接即可翻墙; 20 | 21 | ## 目录结构 : 22 | ``` sh 23 | ├── clash.yaml #clash配置模板 24 | ├── config.example.yaml #以其为例, 自行创建config.yaml 25 | ├── controller/ 26 | │   ├── admin/ #后台api 27 | │   ├── enter.go 28 | │   └── user/ #前台api 29 | ├── dao/ #sql 30 | ├── Dockerfile #构建docker镜像 31 | ├── .editorconfig 32 | ├── .gitattributes 33 | ├── .github/ 34 | │   └── workflows/ #存放GitHub-Actions的工作流文件 35 | ├── .gitignore 36 | ├── .gitmodules 37 | ├── global/ 38 | │   └── global.go #全局变量的初始化 39 | ├── go.mod 40 | ├── go.sum 41 | ├── initialize/ #服务初始化相关 42 | │   ├── server.go #gin服务 43 | │   ├── global/ 44 | │   └── system/ 45 | ├── LICENSE 46 | ├── log/ 47 | │   ├── gin.log #gin日志 48 | │   ├── .gitkeep 49 | │   └── run.log #业务日志 50 | ├── main.go #入口 51 | ├── main_test.go #测试 52 | ├── middleware/ #路由中间件以及验参 53 | ├── model/ 54 | │   ├── common/ #业务要用的结构体 55 | │   ├── config/ #配置文件的结构体 56 | │   └── db/ #数据库模型结构体 57 | ├── README.md 58 | ├── router/ #gin路由 59 | ├── service/ 60 | │   └── user/ 61 | │   ├── enter.go 62 | │   ├── index.go #处理数据库的相关代码 63 | │   ├── tg.go #与telegram-bot交互的逻辑 64 | │   └── validator.go 65 | ├── static/ #静态资源 66 | ├── task/ #任务 67 | │   ├── clear.go #流量清零 68 | │   └── expiry.go #过期用户处理 69 | └── utils/ 70 | └── tool.go 71 | ``` 72 | 73 | ## 准备 74 | * 请准备数据库(默认使用sqlite, 没有db文件则自动在根目录下新增proxy.db文件, 数据库结构参考`initialize/system/db.go`), 并配置`config.yaml` db选项, 可用mysql, 甚至直接配置trojan-go的mysql数据库, 参考 `config.example.yaml` 75 | * 自行创建`telegram-bot`, 将token/id/domain信息配置到`config.yaml`, 可实现tg交互管理用户 76 | * 建议使用的xray很难做用户管理, 故项目不依赖其数据而是外置数据库, 缺点是不能利用xray的流量统计功能(使用trojan-go和其数据库, 则流量统计可用); 未来会对接xray数据, 也许吧! 77 | * 未来支持环境变量配置, 也许吧! 78 | 79 | ## 安装 80 | 81 | ### docker-compose 82 | ``` yaml 83 | version: "3" 84 | services: 85 | proxy: 86 | image: ghcr.io/twbworld/proxy:latest 87 | ports: 88 | - 80:80 89 | volumes: 90 | - ${PWD}/config.yaml:/app/config.yaml:ro 91 | - ${PWD}/proxy.db:proxy.db:rw 92 | ``` 93 | 94 | ### 打包本地运行 95 | ```sh 96 | $ cp config.example.yaml config.yaml 97 | 98 | $ go mod tidy && go build -o server main.go 99 | 100 | $ ./server 101 | ``` 102 | 103 | ## 使用 104 | 105 | > 本项目利用了 `GitHub-Actions` 作为 `持续集成` 处理数据, [相关代码](https://github.com/twbworld/proxy/blob/main/.github/workflows/ci.yml), 也可使用命令行, 下面有介绍 106 | 107 | ### telegram-bot聊天框交互 : 108 | ![](https://cdn.jsdelivr.net/gh/twbworld/hosting@main/img/2023081038595.jpg) 109 | 110 | ### 流量上下行的记录清零 111 | ```sh 112 | $ docker exec -it proxy /app/server -a clear 113 | 或 114 | $ ./server -a clear 115 | ``` 116 | 117 | ### 过期用户处理 118 | ```sh 119 | $ docker exec -it proxy /app/server -a expiry 120 | 或 121 | $ ./server -a expiry 122 | ``` 123 | 124 | 125 | ### 客户端订阅 126 | * `v2ray`订阅地址例子: `www.domain.com/username.html` 127 | > 其中`www.domain.com`是自己的域名,指向该项目监听的端口; `username`是用户名, 如果数据库中存在该用户, 则显示在`config.yaml`下`proxy`选项所配置的vpn信息 128 | * `clash`订阅地址例子: `clash.domain.com/username.html` 129 | > `clash`与前两者不同, 其识别的是配置文件, 所以clash需不同的网址, 且以clash开头的域名, 请自行解析域名;[相关代码](https://github.com/twbworld/proxy/blob/main/controller/user/base.go) 130 | > 提示: 这个客户端使用的`订阅域名`, 跟`连接xray等服务端的域名`是不一样哦; 可以理解为: 利用`订阅域名`获取连接信息, 这些连接信息就包含了用于连接xray服务的域名; 131 | -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | #开启调试 2 | debug: true 3 | #本项目名称 4 | project_name: VPN会员系统 5 | #gin监听的地址 6 | gin_addr: :80 7 | #访问项目的域名,端口默认80(如webhook所用) 8 | domain: https://www.domain.com 9 | #clash默认配置文件 10 | clash_path: clash.yaml 11 | #放置静态文件的目录 12 | static_dir: static 13 | # gin日志文件 14 | gin_log_path: log/gin.log 15 | # 运行日志文件 16 | run_log_path: log/run.log 17 | # 时区 18 | tz: Asia/Shanghai 19 | # 客户端订阅配置 20 | subscribe: 21 | # 文件命名,避免重复修改, clash客户端可能会存在多个订阅配置 22 | filename: "config.yaml" 23 | # 更新间隔(小时) 24 | update_interval: 24 25 | # 卡片链接 26 | page_url: https://twbworld.github.io/proxy 27 | # 数据库配置 28 | database: 29 | #数据库类型(sqlite|mysql); 小应用sqlite, 大应用mysql 30 | type: sqlite 31 | #sqlite文件路径 32 | sqlite_path: "proxy.db" 33 | # mysql地址 34 | mysql_host: "" 35 | # mysql端口 36 | mysql_port: 3306 37 | # mysql数据库名 38 | mysql_dbname: "" 39 | # mysql用户名 40 | mysql_username: "" 41 | # mysql密码 42 | mysql_password: "" 43 | # Telegram聊天室配置 44 | telegram: 45 | # 聊天室token(BotFather创建) 46 | token: "0123456789:AAxxxx" 47 | # 用户id(userinfobot获取) 48 | id: 123456789 49 | # 允许跨域的域名 50 | cors: 51 | - "*" 52 | proxy: 53 | # VLESS-TCP-XTLS-Vision-REALITY 54 | - type: vless 55 | server: www.domain.com 56 | port: 443 57 | uuid: xxxx 58 | flow: xtls-rprx-vision 59 | network: tcp 60 | reality-opts: 61 | public-key: xxxx 62 | short-id: "" 63 | root: true 64 | # VLESS-WS-TLS 65 | - type: vless 66 | server: x.x.x.x 67 | port: 443 68 | uuid: xxxx 69 | network: ws 70 | ws-opts: 71 | path: /vless-ws 72 | headers: 73 | host: www.domain.com 74 | # TROJAN-WS-TLS 75 | - type: trojan 76 | server: www.domain.com 77 | port: 443 78 | uuid: password 79 | network: ws 80 | ws-opts: 81 | path: /trojan-go-ws/ 82 | headers: 83 | host: www.domain.com 84 | -------------------------------------------------------------------------------- /controller/admin/enter.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | type ApiGroup struct { 4 | } 5 | -------------------------------------------------------------------------------- /controller/enter.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import "github.com/twbworld/proxy/controller/user" 4 | import "github.com/twbworld/proxy/controller/admin" 5 | 6 | var Api = new(ApiGroup) 7 | 8 | type ApiGroup struct { 9 | UserApiGroup user.ApiGroup 10 | AdminApiGroup admin.ApiGroup 11 | } 12 | -------------------------------------------------------------------------------- /controller/user/base.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | 11 | "net/http" 12 | "net/url" 13 | 14 | "github.com/twbworld/proxy/global" 15 | "github.com/twbworld/proxy/model/db" 16 | "github.com/twbworld/proxy/service" 17 | "github.com/twbworld/proxy/utils" 18 | ) 19 | 20 | type BaseApi struct{} 21 | 22 | func (b *BaseApi) Subscribe(ctx *gin.Context) { 23 | user := ctx.MustGet(`user`).(*db.Users) 24 | 25 | //获取最后一级子域名名称 26 | protocol := strings.Split(ctx.Request.Host, ".")[0] 27 | res := service.Service.UserServiceGroup.SetProtocol(protocol).Handle(user) 28 | if res == "" && protocol != "" { 29 | res = service.Service.UserServiceGroup.SetProtocol("").Handle(user) 30 | } 31 | 32 | //https://www.clashverge.dev/guide/url_schemes.html 33 | if utils.ContainsAny(ctx.Request.UserAgent(), []string{"clash", "v2ray"}) { 34 | fileName := global.Config.Subscribe.Filename 35 | if fileName == "" { 36 | fileName = user.Username 37 | } 38 | if fileName != "" { 39 | ctx.Header("content-disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", url.QueryEscape(fileName))) 40 | } 41 | if global.Config.Subscribe.UpdateInterval > 0 { 42 | ctx.Header("profile-update-interval", strconv.Itoa(int(global.Config.Subscribe.UpdateInterval))) 43 | } 44 | if global.Config.Subscribe.PageUrl != "" { 45 | ctx.Header("profile-web-page-url", global.Config.Subscribe.PageUrl) 46 | } 47 | if *user.ExpiryDate != "" { 48 | t, err := time.ParseInLocation(time.DateOnly, *user.ExpiryDate, time.UTC) 49 | if err == nil { 50 | //暂不支持流量获取 51 | ctx.Header("subscription-userinfo", fmt.Sprintf("upload=0; download=0; total=0; expire=%d", t.Unix())) 52 | } 53 | } 54 | } 55 | 56 | ctx.String(http.StatusOK, res) 57 | } 58 | -------------------------------------------------------------------------------- /controller/user/enter.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | type ApiGroup struct { 4 | BaseApi 5 | TgApi 6 | } 7 | -------------------------------------------------------------------------------- /controller/user/tg.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | 6 | "github.com/twbworld/proxy/global" 7 | "github.com/twbworld/proxy/service" 8 | "net/http" 9 | ) 10 | 11 | type TgApi struct{} 12 | 13 | func (t *TgApi) Tg(ctx *gin.Context) { 14 | defer func() { 15 | if p := recover(); p != nil { 16 | global.Log.Errorln(p) 17 | service.Service.UserServiceGroup.TgSend(`系统错误, 请按"/start"重新设置`) 18 | ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "系统错误"}) 19 | } 20 | }() 21 | 22 | if err := service.Service.UserServiceGroup.Webhook(ctx); err != nil { 23 | panic(err) 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /dao/common.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/twbworld/proxy/model/db" 7 | ) 8 | 9 | type dbUtils struct{} 10 | 11 | func (u *dbUtils) getInsertSql(d db.Dbfunc, data map[string]interface{}) (string, []interface{}) { 12 | if len(data) < 1 { 13 | return ``, []interface{}{} 14 | } 15 | 16 | var ( 17 | fields strings.Builder 18 | values strings.Builder 19 | sql strings.Builder 20 | args []interface{} = make([]interface{}, 0, len(data)) 21 | ) 22 | 23 | //注意map是无序的 24 | for k, v := range data { 25 | fields.WriteString("`") 26 | fields.WriteString(k) 27 | fields.WriteString("`,") 28 | values.WriteString("?,") 29 | args = append(args, v) 30 | } 31 | 32 | sql.WriteString("INSERT INTO `") 33 | sql.WriteString(d.TableName()) 34 | sql.WriteString("`(") 35 | sql.WriteString(strings.TrimRight(fields.String(), `,`)) 36 | sql.WriteString(`) VALUES(`) 37 | sql.WriteString(strings.TrimRight(values.String(), `,`)) 38 | sql.WriteString(`)`) 39 | 40 | return sql.String(), args 41 | } 42 | 43 | func (u *dbUtils) getUpdateSql(d db.Dbfunc, id uint, data map[string]interface{}) (string, []interface{}) { 44 | if len(data) < 1 { 45 | return ``, []interface{}{} 46 | } 47 | 48 | var ( 49 | fields strings.Builder 50 | sql strings.Builder 51 | args []interface{} = make([]interface{}, 0, len(data)) 52 | ) 53 | 54 | for k, v := range data { 55 | fields.WriteString(" `") 56 | fields.WriteString(k) 57 | fields.WriteString("` = ?,") 58 | args = append(args, v) 59 | } 60 | 61 | sql.WriteString("UPDATE `") 62 | sql.WriteString(d.TableName()) 63 | sql.WriteString("` SET") 64 | sql.WriteString(strings.TrimRight(fields.String(), ",")) 65 | sql.WriteString(" WHERE `id` = ?") 66 | args = append(args, id) 67 | 68 | return sql.String(), args 69 | } 70 | -------------------------------------------------------------------------------- /dao/common_test.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | // Mock implementation of db.Dbfunc 9 | type mockDbfunc struct{} 10 | 11 | func (m *mockDbfunc) TableName() string { 12 | return "test_table" 13 | } 14 | 15 | func TestGetInsertSql(t *testing.T) { 16 | u := &dbUtils{} 17 | d := &mockDbfunc{} 18 | data := map[string]interface{}{ 19 | "column1": "value1", 20 | "column2": 123, 21 | } 22 | 23 | sql, args := u.getInsertSql(d, data) 24 | expectedSql := "INSERT INTO `test_table`(`column1`,`column2`) VALUES(?,?)" 25 | expectedSql2 := "INSERT INTO `test_table`(`column2`,`column1`) VALUES(?,?)" 26 | expectedArgs := []interface{}{"value1", 123} 27 | expectedArgs2 := []interface{}{123, "value1"} 28 | 29 | if !(sql == expectedSql && reflect.DeepEqual(args, expectedArgs)) && !(sql == expectedSql2 && reflect.DeepEqual(args, expectedArgs2)) { 30 | t.Errorf("sql: %s, args: %v", sql, args) 31 | } 32 | } 33 | 34 | func TestGetUpdateSql(t *testing.T) { 35 | u := &dbUtils{} 36 | d := &mockDbfunc{} 37 | id := uint(1) 38 | data := map[string]interface{}{ 39 | "column1": "value1", 40 | "column2": 123, 41 | } 42 | 43 | sql, args := u.getUpdateSql(d, id, data) 44 | expectedSql := "UPDATE `test_table` SET `column1` = ?, `column2` = ? WHERE `id` = ?" 45 | expectedSql2 := "UPDATE `test_table` SET `column2` = ?, `column1` = ? WHERE `id` = ?" 46 | expectedArgs := []interface{}{"value1", 123, id} 47 | expectedArgs2 := []interface{}{123, "value1", id} 48 | 49 | if !(sql == expectedSql && reflect.DeepEqual(args, expectedArgs)) && !(sql == expectedSql2 && reflect.DeepEqual(args, expectedArgs2)) { 50 | t.Errorf("sql: %s, args: %v", sql, args) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /dao/enter.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jmoiron/sqlx" 7 | ) 8 | 9 | //PS:使用Get()需考虑sql.ErrNoRows的情况 10 | 11 | const ( 12 | BaseUserId uint = 1 //"手动添加会面"用到的的虚拟用户 13 | ) 14 | 15 | var ( 16 | DB *sqlx.DB 17 | CanLock bool //是否支持锁(FOR UPDATE) 18 | App *DbGroup = new(DbGroup) 19 | utils *dbUtils = new(dbUtils) 20 | ) 21 | 22 | type DbGroup struct { 23 | UsersDb 24 | SystemInfoDb 25 | } 26 | 27 | func Tx(fc func(tx *sqlx.Tx) error) (err error) { 28 | panicked := true 29 | 30 | tx, err := DB.Beginx() 31 | if err != nil { 32 | err = fmt.Errorf("系统错误[jjhokp9]%s", err) 33 | return 34 | } 35 | defer func() { 36 | if r := recover(); r != nil { 37 | tx.Rollback() 38 | panic(r) 39 | } else if panicked || err != nil { 40 | tx.Rollback() 41 | } 42 | }() 43 | 44 | if err = fc(tx); err == nil { 45 | panicked = false 46 | return tx.Commit() 47 | } 48 | 49 | panicked = false 50 | return 51 | } 52 | -------------------------------------------------------------------------------- /dao/system_info.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "database/sql" 8 | "github.com/twbworld/proxy/model/db" 9 | 10 | "github.com/jmoiron/sqlx" 11 | ) 12 | 13 | type SystemInfoDb struct{} 14 | 15 | func (s *SystemInfoDb) GetSysValByKey(SystemInfo *db.SystemInfo, key string, tx ...*sqlx.Tx) error { 16 | sql := fmt.Sprintf("SELECT * FROM `%s` WHERE `key` = ?", SystemInfo.TableName()) 17 | if len(tx) > 0 && tx[0] != nil { 18 | return tx[0].Get(SystemInfo, sql, key) 19 | } 20 | return DB.Get(SystemInfo, sql, key) 21 | } 22 | 23 | func (s *SystemInfoDb) SaveSysVal(key, value string, tx *sqlx.Tx) (err error) { 24 | if tx == nil { 25 | return errors.New("请使用事务[ios58ja]") 26 | } 27 | 28 | if err := s.CheckSysVal(key, tx); err != nil { 29 | return err 30 | } 31 | 32 | tn := db.SystemInfo{}.TableName() 33 | 34 | var sql string 35 | if CanLock { 36 | sql = fmt.Sprintf("SELECT `id` FROM `%s` WHERE `key`=? FOR UPDATE", tn) 37 | if _, err = tx.Exec(sql, key); err != nil { 38 | return fmt.Errorf("[fui6u]%s", err) 39 | } 40 | } 41 | 42 | sql = fmt.Sprintf("UPDATE `%s` SET `value` = ?, `update_time` = CURRENT_TIMESTAMP WHERE `key` = ?", tn) 43 | _, err = tx.Exec(sql, value, key) 44 | return 45 | } 46 | 47 | func (s *SystemInfoDb) CheckSysVal(key string, tx *sqlx.Tx) (err error) { 48 | var info db.SystemInfo 49 | if s.GetSysValByKey(&info, key, tx) == sql.ErrNoRows { 50 | sql, args := utils.getInsertSql(db.SystemInfo{}, map[string]interface{}{ 51 | "key": key, 52 | }) 53 | _, err = tx.Exec(sql, args...) 54 | } 55 | 56 | return 57 | } 58 | -------------------------------------------------------------------------------- /dao/users.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | "sync" 9 | 10 | "github.com/twbworld/proxy/model/db" 11 | 12 | tool "github.com/twbworld/proxy/utils" 13 | 14 | "github.com/jmoiron/sqlx" 15 | ) 16 | 17 | type UsersDb struct{} 18 | 19 | const ( 20 | SysTimeOut int64 = 30 //流程码过期时间,单位s 21 | QuotaMax float64 = 1073741824 //流量单位转换,入库需要, 1G*1024*1024*1024 = 1073741824byte 22 | ) 23 | 24 | var mu sync.Mutex 25 | 26 | func (u *UsersDb) GetUsers(users *[]db.Users, tx ...*sqlx.Tx) error { 27 | sql := fmt.Sprintf("SELECT * FROM `%s` WHERE `quota` != 0 AND `useDays` != 0", db.Users{}.TableName()) 28 | if len(tx) > 0 && tx[0] != nil { 29 | return tx[0].Select(users, sql) 30 | } 31 | 32 | return DB.Select(users, sql) 33 | } 34 | 35 | func (u *UsersDb) GetUserNames(users *[]db.Users, tx ...*sqlx.Tx) error { 36 | sql := fmt.Sprintf("SELECT `id`, `username` FROM `%s`", db.Users{}.TableName()) 37 | if len(tx) > 0 && tx[0] != nil { 38 | return tx[0].Select(users, sql) 39 | } 40 | 41 | return DB.Select(users, sql) 42 | } 43 | 44 | func (d *UsersDb) GetUsersByUserName(users *db.Users, userName string, tx ...*sqlx.Tx) error { 45 | sql := fmt.Sprintf("SELECT * FROM `%s` WHERE `username`=?", users.TableName()) 46 | if len(tx) > 0 && tx[0] != nil { 47 | return tx[0].Get(users, sql, userName) 48 | } 49 | return DB.Get(users, sql, userName) 50 | } 51 | 52 | func (u *UsersDb) GetUsersByUserId(users *db.Users, id uint, tx ...*sqlx.Tx) error { 53 | sql := fmt.Sprintf("SELECT * FROM `%s` WHERE `id`=?", users.TableName()) 54 | if len(tx) > 0 && tx[0] != nil { 55 | return tx[0].Get(users, sql, id) 56 | } 57 | return DB.Get(users, sql, id) 58 | 59 | } 60 | 61 | func (u *UsersDb) UpdateUsers(user *db.Users, tx *sqlx.Tx) (err error) { 62 | if tx == nil { 63 | return errors.New("请使用事务[odshja]") 64 | } 65 | if user.Id == 0 { 66 | return errors.New("用户ID不能为空[odssihja]") 67 | } 68 | 69 | var sql string 70 | if CanLock { 71 | sql = fmt.Sprintf("SELECT `id` FROM `%s` WHERE `id`=? FOR UPDATE", user.TableName()) 72 | if _, err = tx.Exec(sql, user.Id); err != nil { 73 | return fmt.Errorf("[fuisdku]%s", err) 74 | } 75 | } 76 | 77 | sql, args := utils.getUpdateSql(user, user.Id, map[string]interface{}{ 78 | "username": user.Username, 79 | "password": user.Password, 80 | "passwordShow": user.PasswordShow, 81 | "quota": user.Quota, 82 | "download": user.Download, 83 | "upload": user.Upload, 84 | "useDays": user.UseDays, 85 | "expiryDate": user.ExpiryDate, 86 | }) 87 | _, err = tx.Exec(sql, args...) 88 | 89 | return 90 | } 91 | 92 | func (u *UsersDb) UpdateUsersClear(tx *sqlx.Tx) (err error) { 93 | if tx == nil { 94 | return errors.New("请使用事务[odshlja]") 95 | } 96 | 97 | mu.Lock() 98 | defer mu.Unlock() 99 | 100 | tn := db.Users{}.TableName() 101 | 102 | var sql string 103 | if CanLock { 104 | sql = fmt.Sprintf("LOCK TABLE `%s` WRITE", tn) 105 | if _, err = tx.Exec(sql); err != nil { 106 | return fmt.Errorf("[fuiswsdku]%s", err) 107 | } 108 | } 109 | 110 | sql = fmt.Sprintf("UPDATE `%s` SET `download` = ?, `upload` = ?", tn) 111 | _, err = tx.Exec(sql, 0, 0) 112 | if err != nil { 113 | return 114 | } 115 | 116 | if CanLock { 117 | sql = "UNLOCK TABLES" 118 | if _, err = tx.Exec(sql); err != nil { 119 | return fmt.Errorf("[fuis9u]%s", err) 120 | } 121 | } 122 | 123 | return 124 | } 125 | 126 | func (u *UsersDb) UpdateUsersExpiry(ids []uint, tx *sqlx.Tx) (err error) { 127 | if tx == nil { 128 | return errors.New("请使用事务[dgkhja]") 129 | } 130 | 131 | tn := db.Users{}.TableName() 132 | 133 | var sql string 134 | if CanLock { 135 | sql = fmt.Sprintf("SELECT `id` FROM `%s` WHERE `id` IN (?) FOR UPDATE", tn) 136 | query, args, e := sqlx.In(sql, ids) 137 | if e != nil { 138 | return e 139 | } 140 | if _, err = tx.Exec(tx.Rebind(query), args...); err != nil { 141 | return 142 | } 143 | } 144 | 145 | sql = fmt.Sprintf("UPDATE `%s` SET `quota` = 0 WHERE `id` IN (?)", tn) 146 | q, a, err := sqlx.In(sql, ids) 147 | if err != nil { 148 | return 149 | } 150 | _, err = tx.Exec(tx.Rebind(q), a...) 151 | return 152 | } 153 | 154 | func (u *UsersDb) InsertEmptyUsers(userName string, tx *sqlx.Tx) (err error) { 155 | if tx == nil { 156 | return errors.New("请使用事务[iosdhja]") 157 | } 158 | 159 | sql, args := utils.getInsertSql(db.Users{}, map[string]interface{}{ 160 | "username": userName, 161 | "password": tool.Hash(userName), 162 | "passwordShow": tool.Base64Encode(tool.Hash(userName)), 163 | "quota": int(50 * QuotaMax), 164 | "expiryDate": time.Now().In(time.UTC).AddDate(0, 1, 0).Format(time.DateOnly), 165 | "useDays": 30, 166 | "download": 0, 167 | "upload": 0, 168 | }) 169 | _, err = tx.Exec(sql, args...) 170 | return 171 | } 172 | 173 | func (u *UsersDb) DelUsersHandle(id uint, tx *sqlx.Tx) (err error) { 174 | if tx == nil { 175 | return errors.New("请使用事务[ios8ja]") 176 | } 177 | sql := fmt.Sprintf("DELETE FROM `%s` WHERE `id`=?", db.Users{}.TableName()) 178 | _, err = tx.Exec(sql, id) 179 | return 180 | } 181 | -------------------------------------------------------------------------------- /global/global.go: -------------------------------------------------------------------------------- 1 | package global 2 | 3 | import ( 4 | "time" 5 | 6 | tg "github.com/go-telegram-bot-api/telegram-bot-api/v5" 7 | "github.com/sirupsen/logrus" 8 | "github.com/twbworld/proxy/model/config" 9 | ) 10 | 11 | // 全局变量 12 | // 业务逻辑禁止修改 13 | var ( 14 | Config *config.Config = new(config.Config) //指针类型, 给与其内存空间 15 | Log *logrus.Logger 16 | Tz *time.Location 17 | Bot *tg.BotAPI 18 | ) 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/twbworld/proxy 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/fsnotify/fsnotify v1.8.0 7 | github.com/gin-contrib/cors v1.7.3 8 | github.com/gin-gonic/gin v1.10.0 9 | github.com/go-sql-driver/mysql v1.9.0 10 | github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 11 | github.com/jmoiron/sqlx v1.4.0 12 | github.com/jxskiss/ginregex v0.2.0 13 | github.com/mattn/go-sqlite3 v1.14.24 14 | github.com/robfig/cron/v3 v3.0.1 15 | github.com/sirupsen/logrus v1.9.3 16 | github.com/spf13/viper v1.19.0 17 | github.com/stretchr/testify v1.10.0 18 | ) 19 | 20 | require ( 21 | filippo.io/edwards25519 v1.1.0 // indirect 22 | github.com/bytedance/sonic v1.12.8 // indirect 23 | github.com/bytedance/sonic/loader v0.2.3 // indirect 24 | github.com/cloudwego/base64x v0.1.5 // indirect 25 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 26 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 27 | github.com/gin-contrib/sse v1.0.0 // indirect 28 | github.com/go-playground/locales v0.14.1 // indirect 29 | github.com/go-playground/universal-translator v0.18.1 // indirect 30 | github.com/go-playground/validator/v10 v10.25.0 // indirect 31 | github.com/goccy/go-json v0.10.5 // indirect 32 | github.com/hashicorp/hcl v1.0.0 // indirect 33 | github.com/json-iterator/go v1.1.12 // indirect 34 | github.com/klauspost/cpuid/v2 v2.2.9 // indirect 35 | github.com/leodido/go-urn v1.4.0 // indirect 36 | github.com/magiconair/properties v1.8.9 // indirect 37 | github.com/mattn/go-isatty v0.0.20 // indirect 38 | github.com/mitchellh/mapstructure v1.5.0 // indirect 39 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 40 | github.com/modern-go/reflect2 v1.0.2 // indirect 41 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 42 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 43 | github.com/sagikazarmark/locafero v0.7.0 // indirect 44 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 45 | github.com/sourcegraph/conc v0.3.0 // indirect 46 | github.com/spf13/afero v1.12.0 // indirect 47 | github.com/spf13/cast v1.7.1 // indirect 48 | github.com/spf13/pflag v1.0.6 // indirect 49 | github.com/subosito/gotenv v1.6.0 // indirect 50 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 51 | github.com/ugorji/go/codec v1.2.12 // indirect 52 | go.uber.org/multierr v1.11.0 // indirect 53 | golang.org/x/arch v0.14.0 // indirect 54 | golang.org/x/crypto v0.33.0 // indirect 55 | golang.org/x/exp v0.0.0-20250215185904-eff6e970281f // indirect 56 | golang.org/x/net v0.35.0 // indirect 57 | golang.org/x/sys v0.30.0 // indirect 58 | golang.org/x/text v0.22.0 // indirect 59 | google.golang.org/protobuf v1.36.5 // indirect 60 | gopkg.in/ini.v1 v1.67.0 // indirect 61 | gopkg.in/yaml.v3 v3.0.1 // indirect 62 | ) 63 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/bytedance/sonic v1.12.8 h1:4xYRVRlXIgvSZ4e8iVTlMF5szgpXd4AfvuWgA8I8lgs= 4 | github.com/bytedance/sonic v1.12.8/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8= 5 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 6 | github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0= 7 | github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 8 | github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= 9 | github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 10 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 14 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 16 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 17 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 18 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 19 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 20 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 21 | github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns= 22 | github.com/gin-contrib/cors v1.7.3/go.mod h1:M3bcKZhxzsvI+rlRSkkxHyljJt1ESd93COUvemZ79j4= 23 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 24 | github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= 25 | github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= 26 | github.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY= 27 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= 28 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 29 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 30 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 31 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 32 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 33 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 34 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 35 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 36 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 37 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 38 | github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= 39 | github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= 40 | github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= 41 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 42 | github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo= 43 | github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw= 44 | github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= 45 | github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= 46 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 47 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 48 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 49 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 50 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 51 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 52 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 53 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 54 | github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= 55 | github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= 56 | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 57 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 58 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 59 | github.com/jxskiss/ginregex v0.2.0 h1:ufz3EWGEF4oUJr5PEmS1Z7AzmzRsaIGux2M0Jogfwds= 60 | github.com/jxskiss/ginregex v0.2.0/go.mod h1:3Ioyw1ilM5ZQVsOkCfjbBgcABgbmGErEIQH5gRYU3Wk= 61 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 62 | github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= 63 | github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= 64 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 65 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 66 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 67 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 68 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 69 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 70 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 71 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 72 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 73 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 74 | github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= 75 | github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 76 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 77 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 78 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 79 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 80 | github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 81 | github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 82 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 83 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 84 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 85 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 86 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 87 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 88 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 89 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 90 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 91 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 92 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 93 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 94 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 95 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 96 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 97 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 98 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 99 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 100 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 101 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 102 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 103 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 104 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 105 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 106 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 107 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 108 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 109 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 110 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 111 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 112 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 113 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= 114 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 115 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 116 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 117 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 118 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 119 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 120 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 121 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 122 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 123 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 124 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 125 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 126 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 127 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 128 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 129 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 130 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 131 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 132 | github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= 133 | github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= 134 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 135 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 136 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 137 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 138 | golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4= 139 | golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 140 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 141 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 142 | golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= 143 | golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 144 | golang.org/x/exp v0.0.0-20250215185904-eff6e970281f h1:oFMYAjX0867ZD2jcNiLBrI9BdpmEkvPyi5YrBGXbamg= 145 | golang.org/x/exp v0.0.0-20250215185904-eff6e970281f/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= 146 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 147 | golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= 148 | golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 149 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 150 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 151 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 152 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 153 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 154 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 155 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 156 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 157 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 158 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 159 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 160 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 161 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 162 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 163 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 164 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 165 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 166 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 167 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 168 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 169 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 170 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 171 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 172 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 173 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 174 | -------------------------------------------------------------------------------- /initialize/global/enter.go: -------------------------------------------------------------------------------- 1 | package global 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/fsnotify/fsnotify" 9 | "github.com/gin-gonic/gin" 10 | "github.com/spf13/viper" 11 | 12 | "github.com/twbworld/proxy/global" 13 | "github.com/twbworld/proxy/model/config" 14 | ) 15 | 16 | type GlobalInit struct { 17 | } 18 | 19 | var ( 20 | Conf string 21 | Act string 22 | ) 23 | 24 | func init() { 25 | flag.StringVar(&Conf, "c", "", "choose config file.") 26 | flag.StringVar(&Act, "a", "", `行为,默认为空,即启动服务; "clear": 清除上下行流量记录; "expiry": 处理过期用户`) 27 | } 28 | 29 | func New(configFile ...string) *GlobalInit { 30 | var config string 31 | if gin.Mode() != gin.TestMode { 32 | //避免 单元测试(go test)自动加参数, 导致flag报错 33 | flag.Parse() //解析cli命令参数 34 | if Conf != "" { 35 | config = Conf 36 | } 37 | } 38 | if config == "" && len(configFile) > 0 { 39 | config = configFile[0] 40 | } 41 | if config == "" { 42 | config = `config.yaml` 43 | } 44 | 45 | // 初始化 viper 46 | v := viper.New() 47 | v.SetConfigFile(config) 48 | v.SetConfigType("yaml") 49 | if err := v.ReadInConfig(); err != nil { 50 | panic("读取配置失败[u9ij]: " + config + err.Error()) 51 | } 52 | 53 | // 监听配置文件 54 | v.WatchConfig() 55 | v.OnConfigChange(func(e fsnotify.Event) { 56 | fmt.Println("配置文件变化[djiads]: ", e.Name) 57 | if err := v.Unmarshal(global.Config); err != nil { 58 | if err := v.Unmarshal(global.Config); err != nil { 59 | fmt.Println(err) 60 | } 61 | } 62 | handleConfig(global.Config) 63 | }) 64 | // 将配置赋值给全局变量(结构体需要设置mapstructure的tag) 65 | if err := v.Unmarshal(global.Config); err != nil { 66 | panic("出错[dhfal]: " + err.Error()) 67 | } 68 | 69 | handleConfig(global.Config) 70 | 71 | return &GlobalInit{} 72 | } 73 | 74 | func (g *GlobalInit) Start() { 75 | if err := g.initLog(); err != nil { 76 | panic(err) 77 | } 78 | if err := g.initTz(); err != nil { 79 | panic(err) 80 | } 81 | } 82 | 83 | func handleConfig(c *config.Config) { 84 | c.StaticDir = strings.TrimRight(c.StaticDir, "/") 85 | } 86 | -------------------------------------------------------------------------------- /initialize/global/log.go: -------------------------------------------------------------------------------- 1 | package global 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/twbworld/proxy/utils" 10 | 11 | "github.com/twbworld/proxy/global" 12 | ) 13 | 14 | func (*GlobalInit) initLog() error { 15 | if err := utils.CreateFile(global.Config.RunLogPath); err != nil { 16 | return fmt.Errorf("创建文件错误[oirdtug]: %w", err) 17 | } 18 | 19 | global.Log = logrus.New() 20 | global.Log.SetFormatter(&logrus.JSONFormatter{}) 21 | global.Log.SetLevel(logrus.InfoLevel) 22 | 23 | runfile, err := os.OpenFile(global.Config.RunLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 24 | if err != nil { 25 | return fmt.Errorf("打开文件错误[0atrpf]: %w", err) 26 | } 27 | global.Log.SetOutput(io.MultiWriter(os.Stdout, runfile)) //同时输出到终端和日志 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /initialize/global/tz.go: -------------------------------------------------------------------------------- 1 | package global 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/twbworld/proxy/global" 8 | ) 9 | 10 | func (*GlobalInit) initTz() error { 11 | Location, err := time.LoadLocation(global.Config.Tz) 12 | if err != nil { 13 | return fmt.Errorf("时区配置失败[siortuj]: %w", err) 14 | } 15 | global.Tz = Location 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /initialize/server.go: -------------------------------------------------------------------------------- 1 | package initialize 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "runtime" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/twbworld/proxy/global" 14 | "github.com/twbworld/proxy/initialize/system" 15 | "github.com/twbworld/proxy/router" 16 | "github.com/twbworld/proxy/service" 17 | "github.com/twbworld/proxy/utils" 18 | 19 | "github.com/gin-gonic/gin" 20 | ) 21 | 22 | var server *http.Server 23 | 24 | func InitializeLogger() { 25 | ginfile, err := os.OpenFile(global.Config.GinLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 26 | if err != nil { 27 | global.Log.Fatalf("打开文件错误[fsmk89]: %v", err) 28 | } 29 | gin.DefaultWriter, gin.DefaultErrorWriter = io.MultiWriter(ginfile), global.Log.Out //记录所有日志 30 | gin.DisableConsoleColor() //将日志写入文件时不需要控制台颜色 31 | } 32 | 33 | func Start() { 34 | sys := system.Start() 35 | defer sys.Stop() 36 | 37 | initializeGinServer() 38 | //协程启动服务 39 | go startServer() 40 | 41 | logStartupInfo() 42 | 43 | service.Service.UserServiceGroup.TgService.TgSend("已启动") 44 | 45 | waitForShutdown() 46 | } 47 | 48 | func initializeGinServer() { 49 | mode := gin.ReleaseMode 50 | if global.Config.Debug { 51 | mode = gin.DebugMode 52 | } 53 | gin.SetMode(mode) 54 | 55 | ginServer := gin.Default() 56 | router.Start(ginServer) 57 | 58 | ginServer.ForwardedByClientIP = true 59 | 60 | // ginServer.Run(":80") 61 | server = &http.Server{ 62 | Addr: global.Config.GinAddr, 63 | Handler: ginServer, 64 | } 65 | } 66 | 67 | // 启动HTTP服务器 68 | func startServer() { 69 | if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 70 | global.Log.Panic("服务出错[isjfio]: ", err.Error()) //外部并不能捕获Panic 71 | } 72 | } 73 | 74 | // 记录启动信息 75 | func logStartupInfo() { 76 | var m runtime.MemStats 77 | runtime.ReadMemStats(&m) 78 | 79 | global.Log.Infof("已启动, version: %s, port: %s, pid: %d, mem: %gMiB", runtime.Version(), global.Config.GinAddr, syscall.Getpid(), utils.NumberFormat(float32(m.Alloc)/1024/1024)) 80 | 81 | } 82 | 83 | // 等待关闭信号(ctrl+C) 84 | func waitForShutdown() { 85 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 86 | defer stop() 87 | 88 | <-ctx.Done() //阻塞等待 89 | 90 | //来到这 证明有关闭指令,将进行平滑优雅关闭服务 91 | 92 | global.Log.Infof("程序关闭中..., port: %s, pid: %d", global.Config.GinAddr, syscall.Getpid()) 93 | 94 | shutdownServer() 95 | } 96 | 97 | // 平滑关闭服务器 98 | func shutdownServer() { 99 | //给程序最多5秒处理余下请求 100 | timeoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 101 | defer cancel() 102 | 103 | //关闭监听端口 104 | if err := server.Shutdown(timeoutCtx); err != nil { 105 | global.Log.Panicln("服务关闭出错[oijojiud]", err) 106 | } 107 | service.Service.UserServiceGroup.TgService.TgSend("服务退出成功") 108 | global.Log.Infoln("服务退出成功") 109 | } 110 | -------------------------------------------------------------------------------- /initialize/system/db.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/twbworld/proxy/dao" 8 | "github.com/twbworld/proxy/global" 9 | "github.com/twbworld/proxy/model/db" 10 | "github.com/twbworld/proxy/utils" 11 | 12 | _ "github.com/go-sql-driver/mysql" 13 | "github.com/jmoiron/sqlx" 14 | _ "github.com/mattn/go-sqlite3" 15 | ) 16 | 17 | type mysql struct{} 18 | type sqlite struct{} 19 | type class interface { 20 | connect() error 21 | createTable() error 22 | insertData(string, *sqlx.Tx) error 23 | version() string 24 | } 25 | 26 | func DbStart() error { 27 | var dbRes class 28 | 29 | switch global.Config.Database.Type { 30 | case "mysql": 31 | dbRes = &mysql{} 32 | case "sqlite": 33 | dbRes = &sqlite{} 34 | default: 35 | dbRes = &sqlite{} 36 | } 37 | 38 | if err := dbRes.connect(); err != nil { 39 | return err 40 | } 41 | dbRes.createTable() 42 | return nil 43 | } 44 | 45 | // 关闭数据库连接 46 | func DbClose() error { 47 | if dao.DB != nil { 48 | return dao.DB.Close() 49 | } 50 | return nil 51 | } 52 | 53 | // 连接SQLite数据库 54 | func (s *sqlite) connect() error { 55 | var err error 56 | 57 | if dao.DB, err = sqlx.Open("sqlite3", global.Config.Database.SqlitePath); err != nil { 58 | return fmt.Errorf("数据库连接失败: %w", err) 59 | } 60 | //没有数据库会创建 61 | if err = dao.DB.Ping(); err != nil { 62 | return fmt.Errorf("数据库连接失败: %w", err) 63 | } 64 | 65 | dao.DB.SetMaxOpenConns(16) 66 | dao.DB.SetMaxIdleConns(8) 67 | dao.DB.SetConnMaxLifetime(time.Minute * 5) 68 | 69 | //提高并发 70 | if _, err = dao.DB.Exec("PRAGMA journal_mode = WAL"); err != nil { 71 | return fmt.Errorf("数据库设置失败: %w", err) 72 | } 73 | //超时等待 74 | if _, err = dao.DB.Exec("PRAGMA busy_timeout = 10000;"); err != nil { 75 | return fmt.Errorf("数据库设置失败: %w", err) 76 | } 77 | // 设置同步模式为 NORMAL 78 | if _, err = dao.DB.Exec("PRAGMA synchronous = NORMAL;"); err != nil { 79 | return fmt.Errorf("数据库设置失败: %w", err) 80 | } 81 | 82 | dao.CanLock = false 83 | 84 | global.Log.Infof("%s版本: %s; 地址: %s", global.Config.Database.Type, s.version(), global.Config.Database.SqlitePath) 85 | return nil 86 | } 87 | 88 | func (m *mysql) connect() error { 89 | var err error 90 | dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", global.Config.Database.MysqlUsername, global.Config.Database.MysqlPassword, global.Config.Database.MysqlHost, global.Config.Database.MysqlPort, global.Config.Database.MysqlDbname) 91 | 92 | //也可以使用MustConnect连接不成功就panic 93 | if dao.DB, err = sqlx.Connect("mysql", dsn); err != nil { 94 | return fmt.Errorf("数据库连接失败[rwbhe3]: %s\n%w", dsn, err) 95 | } 96 | 97 | dao.DB.SetMaxOpenConns(16) 98 | dao.DB.SetMaxIdleConns(8) 99 | dao.DB.SetConnMaxLifetime(time.Minute * 5) // 设置连接的最大生命周期 100 | 101 | if err = dao.DB.Ping(); err != nil { 102 | return fmt.Errorf("数据库连接失败: %s\n%w", dsn, err) 103 | } 104 | 105 | dao.CanLock = true 106 | global.Log.Infof("%s版本: %s; 地址: @tcp(%s:%s)/%s", global.Config.Database.Type, m.version(), global.Config.Database.MysqlHost, global.Config.Database.MysqlPort, global.Config.Database.MysqlDbname) 107 | return nil 108 | } 109 | 110 | func (s *sqlite) createTable() error { 111 | var u []string 112 | err := dao.DB.Select(&u, "SELECT name _id FROM sqlite_master WHERE type ='table'") 113 | if err != nil { 114 | return fmt.Errorf("查询表失败: %w", err) 115 | } 116 | 117 | sqls := map[string][]string{ 118 | db.Users{}.TableName(): { 119 | `CREATE TABLE "%s" ( 120 | "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, 121 | "username" text(64) NOT NULL DEFAULT '', 122 | "password" text(56) NOT NULL DEFAULT '', 123 | "passwordShow" text(255) NOT NULL DEFAULT '', 124 | "quota" integer NOT NULL DEFAULT 0, 125 | "download" integer NOT NULL DEFAULT 0, 126 | "upload" integer NOT NULL DEFAULT 0, 127 | "useDays" integer NOT NULL DEFAULT 0, 128 | "expiryDate" text(10) NOT NULL DEFAULT '' 129 | );`, 130 | `CREATE INDEX "password" ON "%s" ( "password" ASC );`, 131 | }, 132 | db.SystemInfo{}.TableName(): { 133 | `CREATE TABLE "%s" ( 134 | "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, 135 | "key" text(255) NOT NULL DEFAULT '', 136 | "value" text(255) NOT NULL DEFAULT '', 137 | "update_time" text NOT NULL DEFAULT '' 138 | );`, 139 | `CREATE UNIQUE INDEX "idx_key" ON "%s" ( "key" ASC );`, 140 | }, 141 | } 142 | 143 | err = dao.Tx(func(tx *sqlx.Tx) (e error) { 144 | for k, v := range sqls { 145 | if utils.InSlice(u, k) < 0 { 146 | for _, val := range v { 147 | if _, e := tx.Exec(fmt.Sprintf(val, k)); e != nil { 148 | return fmt.Errorf("错误[ghjbcvgs]: %s\n%w", val, e) 149 | } 150 | } 151 | if err := s.insertData(k, tx); err != nil { 152 | return fmt.Errorf("插入数据失败: %s\n%w", k, err) 153 | } 154 | global.Log.Infof("创建%s表[dkyjh]", k) 155 | } 156 | } 157 | return 158 | }) 159 | if err != nil { 160 | return fmt.Errorf("事务执行失败: %w", err) 161 | } 162 | return nil 163 | } 164 | 165 | func (m *mysql) createTable() error { 166 | var u []string 167 | err := dao.DB.Select(&u, "SHOW TABLES") 168 | if err != nil { 169 | return fmt.Errorf("插入数据失败: %w", err) 170 | } 171 | 172 | sqls := map[string]string{ 173 | db.Users{}.TableName(): "CREATE TABLE `%s` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `username` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名', `password` char(56) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码', `passwordShow` varchar(255) NOT NULL, `quota` bigint NOT NULL DEFAULT '0' COMMENT '流量限制, 单位byte,1G=1073741824byte;-1:不限', `download` bigint unsigned NOT NULL DEFAULT '0' COMMENT '下行流量', `upload` bigint unsigned NOT NULL DEFAULT '0' COMMENT '上行流量', `useDays` int DEFAULT '0', `expiryDate` char(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT '' COMMENT '限期; 年-月-日', PRIMARY KEY (`id`), KEY `password` (`password`) ) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表';", 174 | db.SystemInfo{}.TableName(): "CREATE TABLE `%s` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `idx_key` (`key`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';", 175 | } 176 | 177 | err = dao.Tx(func(tx *sqlx.Tx) (e error) { 178 | for k, v := range sqls { 179 | if utils.InSlice(u, k) < 0 { 180 | if _, e := tx.Exec(fmt.Sprintf(v, k)); e != nil { 181 | return fmt.Errorf("插入数据失败: %s\n%w", k, err) 182 | } 183 | global.Log.Infof("创建%s表[dfsjh]", k) 184 | if err := m.insertData(k, tx); err != nil { 185 | return fmt.Errorf("插入数据失败[fnko9]: %s\n%w", k, err) 186 | } 187 | } 188 | } 189 | return 190 | }) 191 | if err != nil { 192 | return fmt.Errorf("事务执行失败: %w", err) 193 | } 194 | return nil 195 | } 196 | 197 | func (m *mysql) insertData(t string, tx *sqlx.Tx) error { 198 | return insert(t, tx) 199 | } 200 | 201 | func (m *sqlite) insertData(t string, tx *sqlx.Tx) error { 202 | return insert(t, tx) 203 | } 204 | 205 | func insert(t string, tx *sqlx.Tx) error { 206 | var sqls []string 207 | 208 | switch t { 209 | case db.Users{}.TableName(): 210 | sqls = []string{ 211 | fmt.Sprintf("INSERT INTO `%s`(`username`, `password`, `passwordShow`, `quota`) VALUES('test', '90a3ed9e32b2aaf4c61c410eb925426119e1a9dc53d4286ade99a809', 'OTBhM2VkOWUzMmIyYWFmNGM2MWM0MTBlYjkyNTQyNjExOWUxYTlkYzUzZDQyODZhZGU5OWE4MDk=', -1)", db.Users{}.TableName()), 212 | } 213 | } 214 | 215 | for _, v := range sqls { 216 | global.Log.Infof("创建数据[dfskkjh]%s", v) 217 | if _, e := tx.Exec(v); e != nil { 218 | return fmt.Errorf("错误[gh90iggs]: %s\n%w", v, e) 219 | } 220 | } 221 | return nil 222 | } 223 | 224 | func (*sqlite) version() (t string) { 225 | dao.DB.Get(&t, `SELECT sqlite_version()`) 226 | return 227 | } 228 | 229 | func (*mysql) version() (t string) { 230 | dao.DB.Get(&t, `SELECT version()`) 231 | return 232 | } 233 | -------------------------------------------------------------------------------- /initialize/system/enter.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | type systemRes struct{} 4 | 5 | // 启动系统资源 6 | func Start() *systemRes { 7 | if err := tgStart(); err != nil { 8 | panic(err) 9 | } 10 | if err := timerStart(); err != nil { 11 | panic(err) 12 | } 13 | return &systemRes{} 14 | } 15 | 16 | // 关闭系统资源 17 | func (*systemRes) Stop() { 18 | if err := tgClear(); err != nil { 19 | panic(err) 20 | } 21 | if err := timerStop(); err != nil { 22 | panic(err) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /initialize/system/tg.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "fmt" 5 | 6 | tg "github.com/go-telegram-bot-api/telegram-bot-api/v5" 7 | "github.com/twbworld/proxy/global" 8 | ) 9 | 10 | func tgStart() error { 11 | if global.Config.Debug || global.Config.Telegram.Token == "" { 12 | global.Log.Warnln("Telegram服务未启动: Debug模式或Token为空") 13 | return nil 14 | } 15 | 16 | bot, err := tg.NewBotAPI(global.Config.Telegram.Token) 17 | if err != nil { 18 | return fmt.Errorf("bot初始化失败: %w", err) 19 | } 20 | global.Bot = bot 21 | global.Bot.Debug = global.Config.Debug 22 | 23 | setCommands := tg.NewSetMyCommands(tg.BotCommand{ 24 | Command: "start", 25 | Description: "开始", 26 | }) 27 | if _, err := global.Bot.Request(setCommands); err != nil { 28 | return fmt.Errorf("设置Command失败[ofoan]: %w", err) 29 | } 30 | 31 | if global.Config.Domain == "" { 32 | return fmt.Errorf("配置缺失[654n4]") 33 | } 34 | 35 | wh, _ := tg.NewWebhook(fmt.Sprintf(`%s/wh/tg/%s`, global.Config.Domain, global.Bot.Token)) 36 | if _, err = global.Bot.Request(wh); err != nil { 37 | return fmt.Errorf("设置webhook失败: %w", err) 38 | } 39 | 40 | info, err := global.Bot.GetWebhookInfo() 41 | if err != nil { 42 | return fmt.Errorf("获取webhook失败: %w", err) 43 | } 44 | if info.LastErrorDate != 0 { 45 | return fmt.Errorf("获取tg信息错误[9e0rtji]: %s", info.LastErrorMessage) 46 | } 47 | 48 | global.Log.Printf("成功配置tg[doiasjo]: %s", global.Bot.Self.UserName) 49 | return nil 50 | } 51 | 52 | func tgClear() error { 53 | if global.Bot == nil { 54 | global.Log.Warnln("Telegram服务未启动或已清理") 55 | return nil 56 | } 57 | _, err := global.Bot.Request(tg.DeleteWebhookConfig{}) 58 | if err != nil { 59 | return fmt.Errorf("删除webhook失败[wtuina]: %w", err) 60 | } 61 | global.Log.Infoln("Telegram服务清理成功") 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /initialize/system/timer.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "github.com/robfig/cron/v3" 5 | "github.com/twbworld/proxy/global" 6 | "github.com/twbworld/proxy/service" 7 | "github.com/twbworld/proxy/task" 8 | ) 9 | 10 | var c *cron.Cron 11 | 12 | // startCronJob 启动一个新的定时任务 13 | func startCronJob(task func() error, schedule, name string) error { 14 | _, err := c.AddFunc(schedule, func() { 15 | defer func() { 16 | text := "任务完成" 17 | if p := recover(); p != nil { 18 | text = "任务出错[gqxj]: " + p.(string) 19 | } 20 | service.Service.UserServiceGroup.TgService.TgSend(name + text) 21 | }() 22 | if err := task(); err != nil { 23 | panic(err) 24 | } 25 | }) 26 | return err 27 | } 28 | 29 | func timerStart() error { 30 | c = cron.New([]cron.Option{ 31 | cron.WithLocation(global.Tz), 32 | // cron.WithSeconds(), //精确到秒 33 | }...) 34 | 35 | if err := startCronJob(task.Clear, "0 0 1 * *", "流量清零"); err != nil { 36 | return err 37 | } 38 | 39 | if err := startCronJob(task.Expiry, "0 0 * * *", "处理过期用户"); err != nil { 40 | return err 41 | } 42 | 43 | c.Start() //已含协程 44 | global.Log.Infoln("定时器启动成功") 45 | return nil 46 | } 47 | 48 | func timerStop() error { 49 | if c == nil { 50 | global.Log.Warnln("定时器未启动") 51 | return nil 52 | } 53 | c.Stop() 54 | global.Log.Infoln("定时器停止成功") 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twbworld/proxy/34c4885a0fc2f85cf142ca66ab928d2b47b989a0/log/.gitkeep -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/twbworld/proxy/global" 7 | "github.com/twbworld/proxy/initialize" 8 | initGlobal "github.com/twbworld/proxy/initialize/global" 9 | "github.com/twbworld/proxy/initialize/system" 10 | "github.com/twbworld/proxy/task" 11 | ) 12 | 13 | func main() { 14 | initGlobal.New().Start() 15 | initialize.InitializeLogger() 16 | if err := system.DbStart(); err != nil { 17 | global.Log.Fatalf("连接数据库失败[fbvk89]: %v", err) 18 | } 19 | defer system.DbClose() 20 | 21 | defer func() { 22 | if p := recover(); p != nil { 23 | global.Log.Println(p) 24 | } 25 | }() 26 | 27 | switch initGlobal.Act { 28 | case "": 29 | initialize.Start() 30 | case "clear": 31 | task.Clear() 32 | case "expiry": 33 | task.Expiry() 34 | default: 35 | fmt.Println("参数可选: clear|expiry") 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "io" 9 | "net/http" 10 | "net/http/httptest" 11 | "strconv" 12 | "testing" 13 | "time" 14 | 15 | "github.com/gin-gonic/gin" 16 | "github.com/stretchr/testify/assert" 17 | initGlobal "github.com/twbworld/proxy/initialize/global" 18 | "github.com/twbworld/proxy/initialize/system" 19 | "github.com/twbworld/proxy/model/common" 20 | "github.com/twbworld/proxy/router" 21 | "github.com/twbworld/proxy/utils" 22 | ) 23 | 24 | func TestMain(t *testing.T) { 25 | gin.SetMode(gin.TestMode) 26 | initGlobal.New("config.example.yaml").Start() 27 | if err := system.DbStart(); err != nil { 28 | t.Fatal("数据库连接失败[fsj09]", err) 29 | } 30 | defer func() { 31 | time.Sleep(time.Second * 1) //给足够时间处理数据 32 | system.DbClose() 33 | }() 34 | 35 | ginServer := gin.Default() 36 | router.Start(ginServer) 37 | 38 | //以下是有执行顺序的, 并且库提前有必要数据 39 | testCases := [...]struct { 40 | method string 41 | postRes common.Response 42 | getRes string 43 | url string 44 | status int 45 | postData interface{} 46 | contentType string 47 | }{ 48 | {method: http.MethodGet, url: "http://clash.domain.com/test.html", getRes: `proxies: 49 | - {"name":"外网信息复杂_理智分辨真假_www.domain.com_443","type":"vless"`}, 50 | {method: http.MethodGet, url: "http://domain.com/test.html", getRes: utils.Base64Encode(`vless://xxxx@www.domain.com:443?encryption=none&headerType=none&sni=www.domain.com&fp=chrome&type=tcp&flow=xtls-rprx-vision&pbk=xxxx&sid=&security=reality#外网信息复杂_理智分辨真假_www.domain.com_443 51 | vless://xxxx@x.x.x.x:443?encryption=none&headerType=none&sni=www.domain.com&fp=chrome&type=ws&alpn=h2,http/1.1&host=www.domain.com&path=/vless-ws&security=tls#外网信息复杂_理智分辨真假_x.x.x.x_443 52 | trojan://password@www.domain.com:443?encryption=none&headerType=none&sni=www.domain.com&fp=chrome&type=ws&alpn=h2,http/1.1&host=www.domain.com&path=/trojan-go-ws/&security=tls#外网信息复杂_理智分辨真假_www.domain.com_443`)}, 53 | {method: http.MethodGet, url: "http://domain.com/aa.html", status: http.StatusMovedPermanently, getRes: ``}, 54 | } 55 | 56 | for k, value := range testCases { 57 | t.Run(strconv.FormatInt(int64(k+1), 10)+value.url, func(t *testing.T) { 58 | if value.method == "" { 59 | value.method = http.MethodPost 60 | } 61 | if value.status == 0 { 62 | value.status = 200 63 | } 64 | if value.method == http.MethodPost { 65 | if value.contentType == "" { 66 | value.contentType = "application/json" 67 | } 68 | if value.postRes == (common.Response{}) { 69 | value.postRes.Code = 0 70 | } 71 | } 72 | 73 | requestBody := new(bytes.Buffer) 74 | if value.postData != nil { 75 | if v, ok := value.postData.(*bytes.Buffer); ok { 76 | requestBody = v 77 | } else { 78 | jsonVal, err := json.Marshal(value.postData) 79 | if err != nil { 80 | t.Fatal("json出错[godjg]", err) 81 | } 82 | requestBody = bytes.NewBuffer(jsonVal) 83 | } 84 | } 85 | 86 | b := time.Now().UnixMilli() 87 | 88 | //向注册的路有发起请求 89 | req, err := http.NewRequest(value.method, value.url, requestBody) 90 | if err != nil { 91 | t.Fatal("请求出错[godkojg]", err) 92 | } 93 | if value.method == http.MethodPost { 94 | req.Header.Set("content-type", value.contentType) 95 | } 96 | 97 | res := httptest.NewRecorder() // 构造一个记录 98 | ginServer.ServeHTTP(res, req) //模拟http服务处理请求 99 | 100 | result := res.Result() //response响应 101 | 102 | fmt.Printf("^^^^^^处理用时%d毫秒^^^^^^\n", time.Now().UnixMilli()-b) 103 | 104 | assert.Equal(t, value.status, result.StatusCode) 105 | 106 | body, err := io.ReadAll(result.Body) 107 | if err != nil { 108 | t.Fatal(err) 109 | } 110 | defer result.Body.Close() 111 | 112 | switch value.method { 113 | case http.MethodPost: 114 | var response common.Response 115 | if err := json.Unmarshal(body, &response); err != nil { 116 | t.Fatal("返回错误", err, string(body)) 117 | } 118 | assert.Equal(t, value.postRes.Code, response.Code) 119 | case http.MethodGet: 120 | assert.Contains(t, string(body), value.getRes) 121 | } 122 | 123 | // fmt.Println("request!!!!!!!!!!", string(jsonVal)) 124 | // fmt.Println("response!!!!!!!!!!", utils.Base64Decode(string(body))) 125 | // fmt.Println("response!!!!!!!!!!", string(body)) 126 | 127 | time.Sleep(time.Millisecond * 500) //!!!!!!!!!!!!!!!!!! 128 | 129 | }) 130 | 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /middleware/other.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-contrib/cors" 7 | "github.com/gin-gonic/gin" 8 | "github.com/twbworld/proxy/global" 9 | ) 10 | 11 | // 跨域 12 | func CorsHandle() gin.HandlerFunc { 13 | config := cors.DefaultConfig() 14 | config.AllowOrigins = global.Config.Cors 15 | config.AllowMethods = []string{"OPTIONS", "POST", "GET"} 16 | config.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "authorization"} 17 | return cors.New(config) 18 | } 19 | 20 | func OptionsMethod(ctx *gin.Context) { 21 | if ctx.Request.Method == "OPTIONS" { 22 | ctx.AbortWithStatus(http.StatusNoContent) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /middleware/validator.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "regexp" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/twbworld/proxy/dao" 9 | "github.com/twbworld/proxy/global" 10 | "github.com/twbworld/proxy/model/db" 11 | ) 12 | 13 | var urlPattern = regexp.MustCompile(`^/(.*)\.html$`) 14 | 15 | // 验证TG的token 16 | func ValidatorTgToken(ctx *gin.Context) { 17 | token := ctx.Param("token") 18 | if !global.Config.Debug && global.Config.Telegram.Token == token { 19 | //业务前执行 20 | ctx.Next() 21 | //业务后执行 22 | return 23 | } 24 | 25 | ctx.Abort() 26 | 27 | ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "token"}) 28 | ctx.Redirect(http.StatusMovedPermanently, "/404.html") 29 | } 30 | 31 | func ValidatorSubscribe(ctx *gin.Context) { 32 | params := urlPattern.FindStringSubmatch(ctx.Request.URL.Path) 33 | if len(params) < 2 { 34 | ctx.Abort() 35 | ctx.Redirect(http.StatusMovedPermanently, "/404.html") 36 | return 37 | } 38 | userName := params[1] 39 | if userName == "" || len(userName) < 3 || len(userName) > 50 { 40 | ctx.Abort() 41 | ctx.Redirect(http.StatusMovedPermanently, "/404.html") 42 | return 43 | } 44 | 45 | var user db.Users 46 | if dao.App.UsersDb.GetUsersByUserName(&user, userName) != nil { 47 | ctx.Abort() 48 | ctx.Redirect(http.StatusMovedPermanently, "/404.html") 49 | return 50 | } 51 | 52 | ctx.Set(`user`, &user) 53 | 54 | ctx.Next() //中间件处理完后往下走,也可以使用Abort()终止 55 | } 56 | -------------------------------------------------------------------------------- /model/common/default.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/twbworld/proxy/model/config" 5 | ) 6 | 7 | type ClashVlessVisionReality struct { 8 | *config.Proxy 9 | WsOpts int `json:"-"` 10 | Alpn int `json:"-"` 11 | SkipCertVerify int `json:"-"` 12 | } 13 | type ClashVlessVision struct { 14 | *config.Proxy 15 | WsOpts int `json:"-"` 16 | RealityOpts int `json:"-"` 17 | } 18 | type ClashVlessWs struct { 19 | *config.Proxy 20 | RealityOpts int `json:"-"` 21 | } 22 | type ClashTrojanWs struct { 23 | *config.Proxy 24 | RealityOpts int `json:"-"` 25 | Password string `json:"password"` 26 | } 27 | type ClashTrojan struct { 28 | *config.Proxy 29 | RealityOpts int `json:"-"` 30 | WsOpts int `json:"-"` 31 | Uuid string `json:"-" info:"用户ID或trojan的password"` 32 | Password string `json:"password"` 33 | } 34 | -------------------------------------------------------------------------------- /model/common/request.go: -------------------------------------------------------------------------------- 1 | package common 2 | -------------------------------------------------------------------------------- /model/common/response.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | type Response struct { 10 | Code int8 `json:"code"` 11 | Data interface{} `json:"data"` 12 | Msg string `json:"msg"` 13 | Token string `json:"token,omitempty"` 14 | } 15 | 16 | const ( 17 | successCode = 0 18 | errorCode = 1 19 | defaultSuccessMsg = `ok` 20 | defaultFailMsg = `错误` 21 | ) 22 | 23 | func result(ctx *gin.Context, code int8, msg string, data interface{}) { 24 | ctx.JSON(http.StatusOK, Response{ 25 | Code: code, 26 | Data: data, 27 | Msg: msg, 28 | }) 29 | } 30 | 31 | // 带data 32 | func Success(ctx *gin.Context, data interface{}) { 33 | result(ctx, successCode, defaultSuccessMsg, data) 34 | } 35 | 36 | // 带msg,不带data 37 | func SuccessOk(ctx *gin.Context, message string) { 38 | result(ctx, successCode, message, map[string]interface{}{}) 39 | } 40 | 41 | func Fail(ctx *gin.Context, message string) { 42 | result(ctx, errorCode, message, map[string]interface{}{}) 43 | } 44 | 45 | func FailNotFound(ctx *gin.Context) { 46 | ctx.JSON(http.StatusNotFound, Response{ 47 | Code: errorCode, 48 | Msg: defaultFailMsg, 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /model/config/child.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "fmt" 4 | 5 | type RealityOpts struct { 6 | PublicKey string `json:"public-key" mapstructure:"public-key" yaml:"public-key"` 7 | ShortId string `json:"short-id" mapstructure:"short-id" yaml:"short-id"` 8 | } 9 | type WsOpts struct { 10 | Path string `json:"path" mapstructure:"path" yaml:"path"` 11 | Headers Headers `json:"headers" mapstructure:"headers" yaml:"headers"` 12 | } 13 | type Headers struct { 14 | Host string `json:"host" mapstructure:"host" yaml:"host"` 15 | } 16 | type Proxy struct { 17 | Name string `json:"name" mapstructure:"name" yaml:"name"` 18 | Type string `json:"type" mapstructure:"type" yaml:"type"` 19 | Server string `json:"server" mapstructure:"server" yaml:"server"` 20 | Port string `json:"port" mapstructure:"port" yaml:"port"` 21 | Tls bool `json:"tls" mapstructure:"tls" yaml:"tls"` 22 | Udp bool `json:"udp" mapstructure:"udp" yaml:"udp"` 23 | SkipCertVerify bool `json:"skip-cert-verify" mapstructure:"skip-cert-verify" yaml:"skip-cert-verify"` 24 | ClientFingerprint string `json:"client-fingerprint" mapstructure:"client-fingerprint" yaml:"client-fingerprint"` 25 | Alpn []string `json:"alpn" mapstructure:"alpn" yaml:"alpn"` 26 | Sni string `json:"sni" mapstructure:"sni" yaml:"sni"` 27 | Uuid string `json:"uuid" mapstructure:"uuid" yaml:"uuid"` 28 | Flow string `json:"flow" mapstructure:"flow" yaml:"flow"` 29 | Network string `json:"network" mapstructure:"network" yaml:"network"` 30 | RealityOpts RealityOpts `json:"reality-opts" mapstructure:"reality-opts" yaml:"reality-opts"` 31 | WsOpts WsOpts `json:"ws-opts" mapstructure:"ws-opts" yaml:"ws-opts"` 32 | Root bool `json:"root,omitempty" mapstructure:"root" yaml:"root"` 33 | } 34 | 35 | type Subscribe struct { 36 | Filename string `json:"filename" mapstructure:"filename" yaml:"filename"` 37 | UpdateInterval uint16 `json:"update_interval" mapstructure:"update_interval" yaml:"update_interval"` 38 | PageUrl string `json:"page_url" mapstructure:"page_url" yaml:"page_url"` 39 | } 40 | 41 | type Database struct { 42 | Type string `json:"type" mapstructure:"type" yaml:"type"` 43 | SqlitePath string `json:"sqlite_path" mapstructure:"sqlite_path" yaml:"sqlite_path"` 44 | MysqlHost string `json:"mysql_host" mapstructure:"mysql_host" yaml:"mysql_host"` 45 | MysqlPort string `json:"mysql_port" mapstructure:"mysql_port" yaml:"mysql_port"` 46 | MysqlDbname string `json:"mysql_dbname" mapstructure:"mysql_dbname" yaml:"mysql_dbname"` 47 | MysqlUsername string `json:"mysql_username" mapstructure:"mysql_username" yaml:"mysql_username"` 48 | MysqlPassword string `json:"mysql_password" mapstructure:"mysql_password" yaml:"mysql_password"` 49 | } 50 | 51 | type Telegram struct { 52 | Token string `json:"token" mapstructure:"token" yaml:"token"` 53 | Id int64 `json:"id" mapstructure:"id" yaml:"id"` 54 | } 55 | 56 | func (p *Proxy) SetProxyDefault() { 57 | domain := p.WsOpts.Headers.Host 58 | if domain == "" { 59 | //套cdn(如使用优选ip),则host/sni不等于server 60 | //PS: 这可判断Server是否为域名 61 | domain = p.Server 62 | p.WsOpts.Headers.Host = domain 63 | } 64 | if p.Sni == "" && domain != "" { 65 | p.Sni = domain 66 | } 67 | if p.Name == "" { 68 | p.Name = fmt.Sprintf("外网信息复杂_理智分辨真假_%s_%s", p.Server, p.Port) 69 | } 70 | if p.ClientFingerprint == "" { 71 | p.ClientFingerprint = "chrome" 72 | } 73 | if len(p.Alpn) == 0 { 74 | p.Alpn = []string{"h2", "http/1.1"} 75 | } 76 | p.Tls = true 77 | p.Udp = true 78 | // p.SkipCertVerify = false 79 | } 80 | -------------------------------------------------------------------------------- /model/config/default.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // viper要用到mapstructure/yaml 4 | type Config struct { 5 | Debug bool `json:"debug" mapstructure:"debug" yaml:"debug"` 6 | ProjectName string `json:"project_name" mapstructure:"project_name" yaml:"project_name"` 7 | GinAddr string `json:"gin_addr" mapstructure:"gin_addr" yaml:"gin_addr"` 8 | Domain string `json:"domain" mapstructure:"domain" yaml:"domain"` 9 | ClashPath string `json:"clash_path" mapstructure:"clash_path" yaml:"clash_path"` 10 | StaticDir string `json:"static_dir" mapstructure:"static_dir" yaml:"static_dir"` 11 | GinLogPath string `json:"gin_log_path" mapstructure:"gin_log_path" yaml:"gin_log_path"` 12 | RunLogPath string `json:"run_log_path" mapstructure:"run_log_path" yaml:"run_log_path"` 13 | Tz string `json:"tz" mapstructure:"tz" yaml:"tz"` 14 | Subscribe Subscribe `json:"subscribe" mapstructure:"subscribe" yaml:"subscribe"` 15 | Database Database `json:"database" mapstructure:"database" yaml:"database"` 16 | Telegram Telegram `json:"telegram" mapstructure:"telegram" yaml:"telegram"` 17 | Cors []string `json:"cors" mapstructure:"cors" yaml:"cors"` 18 | Proxy []Proxy `json:"proxy" mapstructure:"proxy" yaml:"proxy"` 19 | } 20 | -------------------------------------------------------------------------------- /model/db/default.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/twbworld/proxy/utils" 5 | ) 6 | 7 | // 所有数据库结构体 都需实现的接口 8 | type Dbfunc interface { 9 | TableName() string 10 | } 11 | 12 | // 可能为null的字段, 用指针 13 | type BaseField struct { 14 | Id uint `db:"id" json:"id"` 15 | AddTime int64 `db:"add_time" json:"add_time"` 16 | UpdateTime int64 `db:"update_time" json:"-"` 17 | } 18 | 19 | func (b *BaseField) AddTimeFormat() string { 20 | return utils.TimeFormat(b.AddTime) 21 | } 22 | 23 | func (b *BaseField) UpdateTimeFormat() string { 24 | return utils.TimeFormat(b.UpdateTime) 25 | } 26 | -------------------------------------------------------------------------------- /model/db/systemInfo.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | type SystemInfo struct { 4 | Id uint `db:"id" json:"id"` 5 | Key string `db:"key" json:"key"` 6 | Value string `db:"value" json:"value"` 7 | UpdateTime string `db:"update_time" json:"update_time"` 8 | } 9 | 10 | func (SystemInfo) TableName() string { 11 | return `system_info` 12 | } 13 | -------------------------------------------------------------------------------- /model/db/users.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | type Users struct { 4 | Id uint `db:"id" json:"id"` 5 | Username string `db:"username" json:"username"` 6 | Password string `db:"password" json:"password"` 7 | PasswordShow string `db:"passwordShow" json:"passwordShow"` 8 | Quota int `db:"quota" json:"quota"` 9 | Download uint `db:"download" json:"download"` 10 | Upload uint `db:"upload" json:"upload"` 11 | UseDays *int `db:"useDays" json:"useDays"` 12 | ExpiryDate *string `db:"expiryDate" json:"expiryDate"` 13 | } 14 | 15 | func (Users) TableName() string { 16 | return `users` 17 | } 18 | -------------------------------------------------------------------------------- /router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/jxskiss/ginregex" 7 | "github.com/twbworld/proxy/controller" 8 | "github.com/twbworld/proxy/global" 9 | "github.com/twbworld/proxy/middleware" 10 | "github.com/twbworld/proxy/model/common" 11 | 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | func Start(ginServer *gin.Engine) { 16 | // 限制form内存(默认32MiB) 17 | ginServer.MaxMultipartMemory = 32 << 20 18 | 19 | ginServer.Use(middleware.CorsHandle(), middleware.OptionsMethod) //全局中间件 20 | 21 | ginServer.StaticFile("/favicon.ico", global.Config.StaticDir+"/favicon.ico") 22 | ginServer.StaticFile("/robots.txt", global.Config.StaticDir+"/robots.txt") 23 | ginServer.LoadHTMLGlob(global.Config.StaticDir + "/*.html") 24 | ginServer.StaticFS("/static", http.Dir(global.Config.StaticDir)) 25 | 26 | // 错误处理路由 27 | errorRoutes := []string{"404.html", "40x.html", "50x.html"} 28 | for _, route := range errorRoutes { 29 | ginServer.GET(route, func(ctx *gin.Context) { 30 | ctx.HTML(http.StatusOK, "404.html", gin.H{"status": route[:3]}) 31 | }) 32 | ginServer.POST(route, func(ctx *gin.Context) { 33 | common.FailNotFound(ctx) 34 | }) 35 | } 36 | 37 | ginServer.NoRoute(func(ctx *gin.Context) { 38 | //内部重定向 39 | ctx.Request.URL.Path = "/404.html" 40 | ginServer.HandleContext(ctx) 41 | //http重定向 42 | // ctx.Redirect(http.StatusMovedPermanently, "/404.html") 43 | }) 44 | 45 | wh := ginServer.Group("wh") 46 | { 47 | wh.POST("/tg/:token", middleware.ValidatorTgToken, controller.Api.UserApiGroup.TgApi.Tg) 48 | } 49 | 50 | // gin路由不支持正则,我服了 51 | regexRouter := ginregex.New(ginServer, nil) 52 | regexRouter.GET(`^/(.*)\.html$`, middleware.ValidatorSubscribe, controller.Api.UserApiGroup.BaseApi.Subscribe) 53 | 54 | } 55 | -------------------------------------------------------------------------------- /service/admin/enter.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | type ServiceGroup struct { 4 | } 5 | -------------------------------------------------------------------------------- /service/enter.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import "github.com/twbworld/proxy/service/user" 4 | import "github.com/twbworld/proxy/service/admin" 5 | 6 | var Service = new(ServiceGroup) 7 | 8 | type ServiceGroup struct { 9 | UserServiceGroup user.ServiceGroup 10 | AdminServiceGroup admin.ServiceGroup 11 | } 12 | -------------------------------------------------------------------------------- /service/user/enter.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | type ServiceGroup struct { 4 | Validator 5 | BaseService 6 | TgService 7 | } 8 | -------------------------------------------------------------------------------- /service/user/index.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "strings" 7 | "time" 8 | 9 | "github.com/twbworld/proxy/global" 10 | "github.com/twbworld/proxy/model/common" 11 | "github.com/twbworld/proxy/model/config" 12 | "github.com/twbworld/proxy/model/db" 13 | "github.com/twbworld/proxy/utils" 14 | ) 15 | 16 | type BaseService struct{} 17 | 18 | type xray struct{} 19 | type clash struct{} 20 | type class interface { 21 | Handle(user *db.Users) string 22 | } 23 | 24 | func (b *BaseService) SetProtocol(t string) class { 25 | switch t { 26 | case "clash": 27 | return &clash{} 28 | default: 29 | return &xray{} 30 | } 31 | } 32 | 33 | func (c *clash) Handle(user *db.Users) string { 34 | 35 | if !checkUser(user) { 36 | return `proxies: 37 | - {name: "!!! 订阅已过期 !!!", type: trojan, server: cn.bing.com, port: 80, password: 0, network: tcp} 38 | proxy-groups: 39 | - {name: "!!!!!! 订阅已过期 !!!!!!", type: select, proxies: ["!!! 订阅已过期 !!!"]}` 40 | } 41 | 42 | if len(global.Config.Proxy) < 1 || !utils.FileExist(global.Config.ClashPath) { 43 | return "" 44 | } 45 | 46 | proxiesName := make([]string, 0, len(global.Config.Proxy)) 47 | var proxies strings.Builder 48 | 49 | for _, value := range global.Config.Proxy { 50 | if value.Server == "" || value.Type == "" || (value.Root && user.Quota != -1) { 51 | continue 52 | } 53 | 54 | combinationType := c.getConfig(&value) 55 | if combinationType == nil { 56 | continue 57 | } 58 | 59 | b, e := json.Marshal(combinationType) 60 | if e != nil || b == nil { 61 | continue 62 | } 63 | 64 | proxies.WriteString("\n - ") //yaml格式 65 | proxies.Write(b) 66 | proxiesName = append(proxiesName, value.Name) 67 | } 68 | 69 | if len(proxiesName) < 1 { 70 | return "" 71 | } 72 | 73 | bn, err := json.Marshal(proxiesName) 74 | if err != nil { 75 | return "" 76 | } 77 | 78 | fres, err := os.ReadFile(global.Config.ClashPath) 79 | if err != nil || len(fres) < 1 { 80 | return "" 81 | } 82 | 83 | replacer := strings.NewReplacer(` [proxies]`, proxies.String(), `[proxies_name]`, string(bn)) 84 | 85 | return replacer.Replace(string(fres)) 86 | } 87 | 88 | func (x *xray) Handle(user *db.Users) string { 89 | if !checkUser(user) { 90 | return utils.Base64Encode("vless://0@cn.bing.com:80?type=tcp#!!! 订阅已过期 !!!") 91 | } 92 | 93 | if len(global.Config.Proxy) < 1 { 94 | return "" 95 | } 96 | var subscription strings.Builder 97 | for _, value := range global.Config.Proxy { 98 | if value.Server == "" || value.Type == "" || (value.Root && user.Quota != -1) { 99 | continue 100 | } 101 | if link := x.getConfig(&value); link != "" { 102 | subscription.WriteString(link) 103 | subscription.WriteString("\n") 104 | } 105 | } 106 | 107 | return utils.Base64Encode(subscription.String()) 108 | } 109 | 110 | func (c *clash) getConfig(value *config.Proxy) any { 111 | value.SetProxyDefault() 112 | 113 | switch { 114 | case value.Type == "vless" && value.Network == "ws" && value.WsOpts.Path != "" && value.Flow == "": 115 | // VLESS-WS-TLS 116 | return common.ClashVlessWs{Proxy: value} 117 | case value.Type == "vless" && value.Flow == "xtls-rprx-vision" && value.RealityOpts.PublicKey != "": 118 | // VLESS-TCP-XTLS-Vision-REALITY 119 | return common.ClashVlessVisionReality{Proxy: value} 120 | case value.Type == "vless" && value.Flow == "xtls-rprx-vision": 121 | // VLESS-TCP-XTLS-Vision 122 | return common.ClashVlessVision{Proxy: value} 123 | case value.Type == "trojan" && value.Network == "ws" && value.WsOpts.Path != "" && value.Flow == "": 124 | // TROJAN-WS-TLS 125 | trojan := common.ClashTrojanWs{Proxy: value} 126 | trojan.Password = value.Uuid 127 | return trojan 128 | case value.Type == "trojan": 129 | // TROJAN-TCP-TLS 130 | trojan := common.ClashTrojan{Proxy: value} 131 | trojan.Password = value.Uuid 132 | return trojan 133 | default: 134 | return nil 135 | } 136 | } 137 | 138 | func (x *xray) getConfig(value *config.Proxy) string { 139 | value.SetProxyDefault() 140 | 141 | if value.Type == "" || value.Server == "" || value.Port == "" { 142 | return "" 143 | } 144 | 145 | var link strings.Builder 146 | link.WriteString(value.Type) 147 | if value.Type == "vless" || value.Type == "trojan" { 148 | link.WriteString("://") 149 | link.WriteString(value.Uuid) 150 | } 151 | 152 | link.WriteString("@") 153 | link.WriteString(value.Server) 154 | link.WriteString(":") 155 | link.WriteString(value.Port) 156 | link.WriteString("?encryption=none&headerType=none&sni=") 157 | link.WriteString(value.Sni) 158 | link.WriteString("&fp=") 159 | link.WriteString(value.ClientFingerprint) 160 | link.WriteString("&type=") 161 | link.WriteString(value.Network) 162 | 163 | switch { 164 | case (value.Type == "vless" || value.Type == "trojan") && value.Network == "ws" && value.WsOpts.Path != "" && value.Flow == "": 165 | // VLESS-WS-TLS || TROJAN-WS-TLS 166 | link.WriteString("&alpn=") 167 | link.WriteString(strings.Join(value.Alpn, ",")) 168 | link.WriteString("&host=") 169 | link.WriteString(value.WsOpts.Headers.Host) 170 | link.WriteString("&path=") 171 | link.WriteString(value.WsOpts.Path) 172 | link.WriteString("&security=tls") 173 | case value.Type == "vless" && value.Flow == "xtls-rprx-vision" && value.RealityOpts.PublicKey != "": 174 | // VLESS-TCP-XTLS-Vision-REALITY 175 | link.WriteString("&flow=") 176 | link.WriteString(value.Flow) 177 | link.WriteString("&pbk=") 178 | link.WriteString(value.RealityOpts.PublicKey) 179 | link.WriteString("&sid=") 180 | link.WriteString(value.RealityOpts.ShortId) 181 | link.WriteString("&security=reality") 182 | case value.Type == "vless" && value.Flow == "xtls-rprx-vision": 183 | // VLESS-TCP-XTLS-Vision 184 | link.WriteString("&alpn=") 185 | link.WriteString(strings.Join(value.Alpn, ",")) 186 | link.WriteString("&flow=") 187 | link.WriteString(value.Flow) 188 | link.WriteString("&security=tls") 189 | case value.Type == "trojan": 190 | // TROJAN-TCP-TLS 191 | link.WriteString("&alpn=") 192 | link.WriteString(strings.Join(value.Alpn, ",")) 193 | link.WriteString("&security=tls") 194 | } 195 | link.WriteString("#") 196 | link.WriteString(value.Name) 197 | 198 | return link.String() 199 | } 200 | 201 | // 检测过期 202 | func checkUser(user *db.Users) bool { 203 | if *user.ExpiryDate == "" || *user.ExpiryDate == "0" { 204 | return true 205 | } 206 | 207 | t, err := time.ParseInLocation(time.DateOnly, *user.ExpiryDate, time.UTC) 208 | 209 | return err != nil || t.AddDate(0, 0, 1).After(time.Now().In(time.UTC)) 210 | } 211 | -------------------------------------------------------------------------------- /service/user/index_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/twbworld/proxy/global" 11 | "github.com/twbworld/proxy/model/common" 12 | "github.com/twbworld/proxy/model/config" 13 | "github.com/twbworld/proxy/model/db" 14 | "github.com/twbworld/proxy/utils" 15 | ) 16 | 17 | func TestSetProtocol(t *testing.T) { 18 | b := &BaseService{} 19 | assert.IsType(t, &clash{}, b.SetProtocol("clash")) 20 | assert.IsType(t, &xray{}, b.SetProtocol("xray")) 21 | assert.IsType(t, &xray{}, b.SetProtocol("unknown")) 22 | } 23 | 24 | func TestClashHandle(t *testing.T) { 25 | global.Tz, _ = time.LoadLocation("Asia/Shanghai") 26 | ti := time.Now().In(time.UTC).AddDate(0, 1, 0).Format(time.DateOnly) 27 | 28 | user := &db.Users{Quota: -1, ExpiryDate: &ti} 29 | proxy := config.Proxy{Type: "vless", Server: "server1", Port: "443", Flow: "xtls-rprx-vision", RealityOpts: config.RealityOpts{PublicKey: "xxx"}} 30 | global.Config.Proxy = []config.Proxy{proxy} 31 | global.Config.ClashPath = "test_clash.yaml" 32 | os.WriteFile(global.Config.ClashPath, []byte(`[proxies_name] [proxies]`), 0644) 33 | defer os.Remove(global.Config.ClashPath) 34 | 35 | c := &clash{} 36 | result := c.Handle(user) 37 | assert.Contains(t, result, fmt.Sprintf(`["外网信息复杂_理智分辨真假_%s_%s"] 38 | - {`, proxy.Server, proxy.Port)) 39 | assert.Contains(t, result, fmt.Sprintf(`"flow":"%s"`, proxy.Flow)) 40 | assert.Contains(t, result, fmt.Sprintf(`"reality-opts":{"public-key":"%s"`, proxy.RealityOpts.PublicKey)) 41 | } 42 | 43 | func TestXrayHandle(t *testing.T) { 44 | global.Tz, _ = time.LoadLocation("Asia/Shanghai") 45 | ti := time.Now().In(time.UTC).AddDate(0, 1, 0).Format(time.DateOnly) 46 | 47 | user := &db.Users{Quota: -1, ExpiryDate: &ti} 48 | proxy := config.Proxy{Type: "vless", Server: "server1", Port: "443", Uuid: "xxx", Network: "ws", WsOpts: config.WsOpts{Path: "xx"}} 49 | global.Config.Proxy = []config.Proxy{proxy} 50 | 51 | x := &xray{} 52 | result := x.Handle(user) 53 | assert.Contains(t, utils.Base64Decode(result), fmt.Sprintf("%s://%s@%s:%s?", proxy.Type, proxy.Uuid, proxy.Server, proxy.Port)) 54 | assert.Contains(t, utils.Base64Decode(result), fmt.Sprintf("type=%s", proxy.Network)) 55 | } 56 | 57 | func TestClashGetConfig(t *testing.T) { 58 | c := &clash{} 59 | proxy := config.Proxy{Type: "vless", Network: "ws", WsOpts: config.WsOpts{Path: "/ws"}} 60 | result := c.getConfig(&proxy) 61 | assert.IsType(t, common.ClashVlessWs{}, result) 62 | } 63 | 64 | func TestXrayGetConfig(t *testing.T) { 65 | x := &xray{} 66 | proxy := config.Proxy{Type: "vless", Server: "server1", Port: "443", Uuid: "uuid1"} 67 | result := x.getConfig(&proxy) 68 | assert.Contains(t, result, "vless://uuid1@server1:443") 69 | } 70 | 71 | func TestCheckUser(t *testing.T) { 72 | global.Tz, _ = time.LoadLocation("Asia/Shanghai") 73 | ti := time.Now().In(time.UTC) 74 | t1 := ti.Format(time.DateOnly) 75 | t2 := ti.AddDate(0, -1, 0).Format(time.DateOnly) 76 | 77 | user := &db.Users{ExpiryDate: &t1} 78 | assert.True(t, checkUser(user)) 79 | 80 | user.ExpiryDate = &t2 81 | assert.False(t, checkUser(user)) 82 | } 83 | -------------------------------------------------------------------------------- /service/user/tg.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/gin-gonic/gin" 15 | tg "github.com/go-telegram-bot-api/telegram-bot-api/v5" 16 | "github.com/jmoiron/sqlx" 17 | "github.com/twbworld/proxy/dao" 18 | "github.com/twbworld/proxy/global" 19 | "github.com/twbworld/proxy/model/db" 20 | ) 21 | 22 | type TgService struct{} 23 | 24 | type tgConfig struct { 25 | update *tg.Update 26 | } 27 | 28 | var ( 29 | step3Regex = regexp.MustCompile(`(.+)@(\d+)`) 30 | step4Regex = regexp.MustCompile(`(.+)@(\d+)@(.+)`) 31 | userNameRegex = regexp.MustCompile("^[a-zA-Z0-9_-]{4,64}$") 32 | lock sync.RWMutex 33 | ) 34 | 35 | // 向Tg发送信息(请用协程执行) 36 | func (t *TgService) TgSend(text string) (err error) { 37 | if global.Bot == nil { 38 | return fmt.Errorf("[ertioj98]出错") 39 | } 40 | 41 | if len(text) < 1 { 42 | return fmt.Errorf("[sioejn89]出错") 43 | } 44 | 45 | lock.RLock() 46 | defer lock.RUnlock() 47 | 48 | var str strings.Builder 49 | str.WriteString(`[`) 50 | str.WriteString(global.Config.ProjectName) 51 | str.WriteString(`]`) 52 | str.WriteString(text) 53 | 54 | msg := tg.NewMessage(global.Config.Telegram.Id, str.String()) 55 | // msg.ParseMode = "MarkdownV2" //使用Markdown格式, 需要对特殊字符进行转义 56 | 57 | _, err = global.Bot.Send(msg) 58 | return 59 | } 60 | 61 | func (t *TgService) Webhook(ctx *gin.Context) (err error) { 62 | tc := &tgConfig{ 63 | update: &tg.Update{}, 64 | } 65 | 66 | if err = json.NewDecoder(ctx.Request.Body).Decode(tc.update); err != nil { 67 | return 68 | } 69 | 70 | return tc.handle() 71 | } 72 | 73 | func (c *tgConfig) handle() error { 74 | 75 | // st, _ := json.Marshal(update) 76 | // fmt.Println(string(st)) 77 | 78 | if c.update.Message != nil { 79 | // (对话第一步) 和 (对话第五步) 80 | return c.firstStep() 81 | } else if c.update.CallbackQuery != nil { 82 | data := c.update.CallbackQuery.Data 83 | if data == "" { 84 | return errors.New("参数错误[gdoigjiod]") 85 | } 86 | 87 | //操作@用户id 88 | if params := step4Regex.FindStringSubmatch(data); len(params) > 3 { 89 | // (对话第四步) 90 | params = params[:4] //消除边界检查 91 | intNum, err := strconv.Atoi(params[2]) 92 | if err != nil { 93 | return errors.New("参数错误[oidfjgoid]") 94 | } 95 | return c.input(uint(intNum), params[3]) 96 | } else if params := step3Regex.FindStringSubmatch(data); len(params) > 2 { 97 | params = params[:3] 98 | // (对话第三步) 99 | intNum, err := strconv.Atoi(params[2]) 100 | if err != nil { 101 | return errors.New("参数错误[opdpp]") 102 | } 103 | return c.actionType(params[1], uint(intNum)) 104 | 105 | } else { 106 | // (对话第二步) 107 | return c.selectUser() 108 | } 109 | 110 | } 111 | return nil 112 | } 113 | 114 | func (c *tgConfig) firstStep() error { 115 | msg := tg.NewMessage(c.update.Message.Chat.ID, "") 116 | //ReplyKeyboard位于输入框下的按钮 117 | msg.Text, msg.ReplyMarkup = "命令不存在!!!", tg.NewReplyKeyboard( 118 | tg.NewKeyboardButtonRow( 119 | tg.NewKeyboardButton("/start"), 120 | ), 121 | ) 122 | 123 | var info db.SystemInfo 124 | errSys := dao.App.SystemInfoDb.GetSysValByKey(&info, strconv.FormatInt(c.update.Message.Chat.ID, 10)+"_step") 125 | 126 | if c.update.Message.IsCommand() { 127 | switch c.update.Message.Command() { 128 | case "start", "help": 129 | msg.Text = "\nHello " + c.update.Message.From.FirstName + c.update.Message.From.LastName 130 | //InlineKeyboard位于对话框下的按钮 131 | msg.ReplyMarkup = tg.NewInlineKeyboardMarkup( 132 | tg.NewInlineKeyboardRow( 133 | tg.NewInlineKeyboardButtonData("查询用户", "user_select"), 134 | tg.NewInlineKeyboardButtonData("新增用户", "user_insert"), 135 | ), 136 | ) 137 | 138 | if errSys == nil || info.Value != "" { 139 | //初始化,清空流程码 140 | err := dao.Tx(func(tx *sqlx.Tx) (e error) { 141 | return dao.App.SystemInfoDb.SaveSysVal(strconv.FormatInt(c.update.Message.Chat.ID, 10)+"_step", "", tx) 142 | }) 143 | if err != nil { 144 | return err 145 | } 146 | } 147 | 148 | default: 149 | msg.ReplyToMessageID = c.update.Message.MessageID //引用对话 150 | } 151 | } else { 152 | msg.ReplyToMessageID = c.update.Message.MessageID //引用对话 153 | 154 | if errSys != nil { 155 | goto SEND 156 | } else if t, err := time.ParseInLocation(time.DateTime, info.UpdateTime, time.UTC); err != nil || time.Now().Unix()-t.Unix() > dao.SysTimeOut { 157 | if err != nil { 158 | return errors.New("系统错误[adfaio]" + err.Error()) 159 | } 160 | 161 | //数据过期 162 | (&TgService{}).TgSend(`输入超时, 请按"/start"重新设置:`) 163 | return nil 164 | } 165 | 166 | if info.Value == "user_insert" { 167 | return c.userInsert(&info) 168 | } 169 | 170 | params := step4Regex.FindStringSubmatch(info.Value) 171 | if len(params) < 4 { 172 | return errors.New("参数错误[fijsa]") 173 | } 174 | 175 | params = params[:4] 176 | 177 | intNum, err := strconv.Atoi(params[2]) 178 | if err != nil { 179 | return errors.New("错误[jklsd]: " + err.Error()) 180 | } 181 | var user db.Users 182 | if err = dao.App.UsersDb.GetUsersByUserId(&user, uint(intNum)); err != nil { 183 | if err == sql.ErrNoRows { 184 | return errors.New("用户不存在[tigfffhh]") 185 | } 186 | return errors.New("错误[tigfhh]: " + err.Error()) 187 | } 188 | 189 | switch params[3] { 190 | case "quota": 191 | text, err := strconv.ParseFloat(c.update.Message.Text, 64) 192 | if err != nil || (text != -1 && text < 0) { 193 | msg.Text = fmt.Sprintf("请输入限制\\[`%s`\\]的流量数值, 单位为*G*; 如想限制该用户为2\\.3G流量,则输入\"2\\.3\", 不限则输入\"\\-1\"", user.Username) 194 | msg.ParseMode = "MarkdownV2" 195 | 196 | err := dao.Tx(func(tx *sqlx.Tx) (e error) { 197 | //为了更新时间字段update_time 198 | return dao.App.SystemInfoDb.SaveSysVal(info.Key, info.Value, tx) 199 | }) 200 | if err != nil { 201 | return err 202 | } 203 | goto SEND 204 | } 205 | if c.update.Message.Text != "-1" { 206 | text = text * dao.QuotaMax 207 | } 208 | user.Quota = int(text) 209 | case "expiryDate": 210 | t, err := time.ParseInLocation(time.DateOnly, c.update.Message.Text, global.Tz) 211 | if err != nil { 212 | msg.Text = fmt.Sprintf("请输入限制\\[`%s`\\]的到期时间, 格式为\"2066\\-06\\-06\"", user.Username) 213 | msg.ParseMode = "MarkdownV2" 214 | err := dao.Tx(func(tx *sqlx.Tx) (e error) { 215 | //为了更新时间字段update_time 216 | return dao.App.SystemInfoDb.SaveSysVal(info.Key, info.Value, tx) 217 | }) 218 | if err != nil { 219 | return err 220 | } 221 | goto SEND 222 | } 223 | ExpiryDate := t.In(time.UTC).Format(time.DateOnly) 224 | user.ExpiryDate = &ExpiryDate 225 | default: 226 | return errors.New("错误[kdfhf]: " + params[3]) 227 | } 228 | 229 | err = dao.Tx(func(tx *sqlx.Tx) (e error) { 230 | if e = dao.App.UsersDb.UpdateUsers(&user, tx); e != nil { 231 | return errors.New("错误[jkljkjkl]: " + e.Error()) 232 | } 233 | //修改成功后,清空流程码 234 | return dao.App.SystemInfoDb.SaveSysVal(info.Key, "", tx) 235 | }) 236 | if err != nil { 237 | return err 238 | } 239 | 240 | msg.Text = "*修改成功\\!\\!\\!*\n" + c.getUserMarkdownV2Text(&user) 241 | msg.ReplyMarkup = tg.NewInlineKeyboardMarkup( 242 | tg.NewInlineKeyboardRow( 243 | tg.NewInlineKeyboardButtonData("修改此用户["+user.Username+"]", fmt.Sprintf("user_update@%d", user.Id)), 244 | ), 245 | ) 246 | msg.ParseMode = "MarkdownV2" 247 | } 248 | SEND: 249 | go func() { 250 | if _, err := global.Bot.Send(msg); err != nil { 251 | global.Log.Error(err) 252 | } 253 | }() 254 | return nil 255 | } 256 | 257 | func (c *tgConfig) selectUser() error { 258 | var err error 259 | switch c.update.CallbackQuery.Data { 260 | case "user_select": 261 | var usersMysql []db.Users 262 | if err = dao.App.UsersDb.GetUserNames(&usersMysql); err != nil { 263 | return errors.New("错误[lkffgdh]" + err.Error()) 264 | } 265 | row, mkrow := make([]tg.InlineKeyboardButton, 0, 2), make([][]tg.InlineKeyboardButton, 0, len(usersMysql)/2+1) 266 | for _, v := range usersMysql { 267 | row = append(row, tg.NewInlineKeyboardButtonData(v.Username, c.update.CallbackQuery.Data+"@"+strconv.Itoa(int(v.Id)))) 268 | if len(row) == 2 { 269 | //每行两个进行展示 270 | mkrow = append(mkrow, tg.NewInlineKeyboardRow(row...)) 271 | row = make([]tg.InlineKeyboardButton, 0, 2) 272 | } 273 | } 274 | if len(row) > 0 { 275 | mkrow = append(mkrow, tg.NewInlineKeyboardRow(row...)) 276 | } 277 | 278 | msg := tg.NewEditMessageTextAndMarkup(c.update.CallbackQuery.Message.Chat.ID, c.update.CallbackQuery.Message.MessageID, "选择*查询*的用户", tg.NewInlineKeyboardMarkup(mkrow...)) 279 | msg.ParseMode = "MarkdownV2" 280 | 281 | go func() { 282 | if _, err := global.Bot.Send(msg); err != nil { 283 | global.Log.Error("错误[iofgjiosj]" + err.Error()) 284 | } 285 | }() 286 | 287 | case "user_insert": 288 | err = dao.Tx(func(tx *sqlx.Tx) (e error) { 289 | return dao.App.SystemInfoDb.SaveSysVal(strconv.FormatInt(c.update.CallbackQuery.Message.Chat.ID, 10)+"_step", "user_insert", tx) 290 | }) 291 | if err != nil { 292 | return err 293 | } 294 | 295 | msg := tg.NewMessage(c.update.CallbackQuery.Message.Chat.ID, "请输入用户名称, 4\\-64个字符以内的英文/数字/符号\n例:`210606_abc`") 296 | msg.ParseMode = "MarkdownV2" 297 | 298 | go func() { 299 | if _, err := global.Bot.Send(msg); err != nil { 300 | global.Log.Error("错误[iofgjfiosj]" + err.Error()) 301 | } 302 | }() 303 | 304 | default: 305 | msg := tg.NewMessage(c.update.CallbackQuery.Message.Chat.ID, "命令不存在!!!") 306 | go func() { 307 | if _, err := global.Bot.Send(msg); err != nil { 308 | global.Log.Error("错误[iofgsjiosj]" + err.Error()) 309 | } 310 | }() 311 | } 312 | 313 | return nil 314 | } 315 | 316 | func (c *tgConfig) actionType(act string, userId uint) error { 317 | var ( 318 | user db.Users 319 | err error 320 | ) 321 | if err = dao.App.UsersDb.GetUsersByUserId(&user, userId); err != nil { 322 | (&TgService{}).TgSend("找不到用户") 323 | return errors.New("错误[jfdsgsd]" + err.Error()) 324 | } 325 | 326 | msg := tg.NewEditMessageTextAndMarkup(c.update.CallbackQuery.Message.Chat.ID, c.update.CallbackQuery.Message.MessageID, c.getUserMarkdownV2Text(&user), tg.NewInlineKeyboardMarkup([]tg.InlineKeyboardButton{})) 327 | msg.ParseMode = "MarkdownV2" 328 | 329 | switch act { 330 | case "user_select": 331 | ikb := tg.NewInlineKeyboardMarkup( 332 | tg.NewInlineKeyboardRow( 333 | tg.NewInlineKeyboardButtonData("修改此用户["+user.Username+"]", fmt.Sprintf("user_update@%d", user.Id)), 334 | ), 335 | ) 336 | msg.ReplyMarkup = &ikb 337 | goto SEND3 338 | case "user_update": 339 | ikb := tg.NewInlineKeyboardMarkup( 340 | tg.NewInlineKeyboardRow( 341 | // tg.NewInlineKeyboardButtonData("限流", fmt.Sprintf("user_update@%d@%s", user.Id, "quota")), 342 | tg.NewInlineKeyboardButtonData("到期", fmt.Sprintf("user_update@%d@%s", user.Id, "expiryDate")), 343 | ), 344 | ) 345 | msg.ReplyMarkup = &ikb 346 | msg.Text = fmt.Sprintf("选择修改\\[`%s`\\]的设置\n", user.Username) + msg.Text 347 | goto SEND3 348 | default: 349 | return (&TgService{}).TgSend("参数错误[goidjgd]") 350 | } 351 | 352 | SEND3: 353 | go func() { 354 | if _, err := global.Bot.Send(msg); err != nil { 355 | global.Log.Error(err) 356 | } 357 | }() 358 | return nil 359 | } 360 | 361 | func (c *tgConfig) input(userId uint, value string) error { 362 | var user db.Users 363 | if dao.App.UsersDb.GetUsersByUserId(&user, userId) != nil { 364 | (&TgService{}).TgSend("找不到用户") 365 | return errors.New("找不到用户[kysafd]") 366 | } 367 | 368 | msg := tg.NewEditMessageTextAndMarkup(c.update.CallbackQuery.Message.Chat.ID, c.update.CallbackQuery.Message.MessageID, "", tg.NewInlineKeyboardMarkup([]tg.InlineKeyboardButton{})) 369 | msg.ParseMode = "MarkdownV2" 370 | 371 | switch value { 372 | case "quota": 373 | msg.Text = fmt.Sprintf("请输入限制\\[`%s`\\]的流量数值, 单位为*G*; 如想限制该用户为2\\.3G流量,则输入\"2\\.3\", 不限则输入\"\\-1\"\n", user.Username) + c.getUserMarkdownV2Text(&user) 374 | case "expiryDate": 375 | msg.Text = fmt.Sprintf("请输入限制\\[`%s`\\]的到期时间, 格式为\"2066\\-06\\-06\"\n", user.Username) + c.getUserMarkdownV2Text(&user) 376 | default: 377 | msg.Text = "命令不存在!!!" 378 | } 379 | 380 | go func() { 381 | if _, err := global.Bot.Send(msg); err != nil { 382 | global.Log.Error("错误[iodiosj]" + err.Error()) 383 | } 384 | }() 385 | 386 | return dao.Tx(func(tx *sqlx.Tx) (e error) { 387 | return dao.App.SystemInfoDb.SaveSysVal(strconv.FormatInt(c.update.CallbackQuery.Message.Chat.ID, 10)+"_step", c.update.CallbackQuery.Data, tx) 388 | }) 389 | } 390 | 391 | func (c *tgConfig) userInsert(info *db.SystemInfo) (err error) { 392 | msg := tg.NewMessage(c.update.Message.Chat.ID, "") 393 | msg.ReplyToMessageID = c.update.Message.MessageID //引用对话 394 | 395 | var user db.Users 396 | if !userNameRegex.MatchString(c.update.Message.Text) { 397 | msg.Text = "请输入用户名称, 64个字符以内的英文/数字/字符\n例:`210606_abc`" 398 | msg.ParseMode = "MarkdownV2" 399 | 400 | go dao.Tx(func(tx *sqlx.Tx) (e error) { 401 | return dao.App.SystemInfoDb.SaveSysVal(info.Key, info.Value, tx) 402 | }) 403 | 404 | goto SEND2 405 | } 406 | 407 | if dao.App.UsersDb.GetUsersByUserName(&user, c.update.Message.Text) == nil { 408 | msg.Text = strings.Replace(fmt.Sprintf("*用户`%s`已存在\\!\\!\\!*\n", c.update.Message.Text), `-`, `\-`, -1) + c.getUserMarkdownV2Text(&user) 409 | msg.ParseMode = "MarkdownV2" 410 | 411 | go dao.Tx(func(tx *sqlx.Tx) (e error) { 412 | return dao.App.SystemInfoDb.SaveSysVal(info.Key, info.Value, tx) 413 | }) 414 | 415 | goto SEND2 416 | } 417 | 418 | err = dao.Tx(func(tx *sqlx.Tx) (e error) { 419 | if e = dao.App.UsersDb.InsertEmptyUsers(c.update.Message.Text, tx); e != nil { 420 | msg.Text = `系统错误, 请按"/start"重新设置4` 421 | go dao.App.SystemInfoDb.SaveSysVal(info.Key, info.Value, tx) 422 | return e 423 | } 424 | if e = dao.App.UsersDb.GetUsersByUserName(&user, c.update.Message.Text, tx); e != nil { 425 | msg.Text = `系统错误, 请按"/start"重新设置5` 426 | go dao.App.SystemInfoDb.SaveSysVal(info.Key, info.Value, tx) 427 | return e 428 | } 429 | return e 430 | }) 431 | if err != nil { 432 | goto SEND2 433 | } 434 | 435 | msg.Text = "*新增成功\\!\\!\\!*\n" + c.getUserMarkdownV2Text(&user) 436 | msg.ParseMode = "MarkdownV2" 437 | msg.ReplyMarkup = tg.NewInlineKeyboardMarkup( 438 | tg.NewInlineKeyboardRow( 439 | tg.NewInlineKeyboardButtonData("修改此用户["+user.Username+"]", fmt.Sprintf("user_update@%d", user.Id)), 440 | ), 441 | ) 442 | 443 | SEND2: 444 | go func() { 445 | if _, err := global.Bot.Send(msg); err != nil { 446 | global.Log.Error(err) 447 | } 448 | }() 449 | return nil 450 | } 451 | 452 | func (c *tgConfig) getUserMarkdownV2Text(user *db.Users) string { 453 | // quota := "不限" 454 | // if user.Quota != -1 { 455 | // quota = fmt.Sprintf("%.1f", float64(user.Quota)/dao.App.UsersDb.QuotaMax) + "G" 456 | // } 457 | // download := fmt.Sprintf("%.1f", float64(user.Download)/dao.App.UsersDb.QuotaMax) 458 | // upload := fmt.Sprintf("%.1f", float64(user.Upload)/dao.App.UsersDb.QuotaMax) 459 | ExpiryDateStr := *user.ExpiryDate 460 | if t, err := time.Parse(time.DateOnly, *user.ExpiryDate); err != nil { 461 | ExpiryDateStr = t.In(global.Tz).Format(time.DateOnly) 462 | } 463 | text := fmt.Sprintf("账号: `%s`\nid: %d\n到期: %s", user.Username, user.Id, ExpiryDateStr) 464 | return strings.Replace(strings.Replace(text, `-`, `\-`, -1), `.`, `\.`, -1) 465 | } 466 | -------------------------------------------------------------------------------- /service/user/validator.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | type Validator struct{} 4 | 5 | // 检验LoginPost参数 6 | func (v *Validator) ValidatorSubscribe() error { 7 | return nil 8 | } 9 | -------------------------------------------------------------------------------- /static/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{.status}} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 151 | 152 | 153 | 154 | 155 |
{{.status}}
156 | 157 | 158 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twbworld/proxy/34c4885a0fc2f85cf142ca66ab928d2b47b989a0/static/favicon.ico -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | 3 | Disallow: / 4 | -------------------------------------------------------------------------------- /task/clear.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | "github.com/twbworld/proxy/dao" 6 | "github.com/twbworld/proxy/global" 7 | ) 8 | 9 | // 流量上下行的记录清零 10 | func Clear() error { 11 | err := dao.Tx(func(tx *sqlx.Tx) (e error) { 12 | e = dao.App.UsersDb.UpdateUsersClear(tx) 13 | return 14 | }) 15 | 16 | if err != nil { 17 | global.Log.Errorln("清除失败[weij0]") 18 | return err 19 | } 20 | 21 | global.Log.Infoln("成功清零流量") 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /task/expiry.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/twbworld/proxy/dao" 8 | "github.com/twbworld/proxy/global" 9 | "github.com/twbworld/proxy/model/db" 10 | "github.com/twbworld/proxy/service" 11 | ) 12 | 13 | // 过期用户处理 14 | func Expiry() error { 15 | var users []db.Users 16 | 17 | if err := dao.App.UsersDb.GetUsers(&users); err != nil { 18 | global.Log.Errorf("查询失败[fsuojnv]: %v", err) 19 | return err 20 | } 21 | 22 | if len(users) < 1 { 23 | global.Log.Infoln("没有过期用户[gsfiod]") 24 | return nil 25 | } 26 | 27 | now := time.Now().In(time.UTC) 28 | t, err := time.ParseInLocation(time.DateTime, now.Format(time.DateOnly+" 00:00:01"), time.UTC) 29 | if err != nil { 30 | global.Log.Errorf("时间出错[djaksofja]: %v", err) 31 | return err 32 | } 33 | t1, t2 := now.AddDate(0, 0, -7), time.Now().In(time.UTC).AddDate(0, 0, -5) 34 | 35 | ids := make([]uint, 0, len(users)) 36 | for _, user := range users { 37 | if user.ExpiryDate == nil || *user.ExpiryDate == "" || user.Id < 1 { 38 | continue 39 | } 40 | ti, err := time.ParseInLocation(time.DateOnly, *user.ExpiryDate, time.UTC) 41 | if err != nil { 42 | continue 43 | } 44 | if t.After(ti) { 45 | ids = append(ids, user.Id) 46 | } 47 | 48 | if t1.After(ti) && t2.Before(ti) { 49 | go service.Service.UserServiceGroup.TgService.TgSend(user.Username + "快到期" + ti.Format(time.DateOnly)) 50 | } 51 | } 52 | if len(ids) == 0 { 53 | global.Log.Infoln("没有过期用户[ofijsdfio]") 54 | return nil 55 | } 56 | 57 | err = dao.Tx(func(tx *sqlx.Tx) (e error) { 58 | return dao.App.UsersDb.UpdateUsersExpiry(ids, tx) 59 | }) 60 | 61 | if err != nil { 62 | global.Log.Errorln("更新用户过期状态失败[4r789s]", ids, err) 63 | return err 64 | } 65 | 66 | global.Log.Infoln("成功处理过期用户[weou89]: ", ids) 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /utils/tool.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/base64" 6 | "encoding/hex" 7 | "math" 8 | "os" 9 | "path/filepath" 10 | "reflect" 11 | "strings" 12 | "time" 13 | 14 | "github.com/twbworld/proxy/global" 15 | ) 16 | 17 | type timeNumber interface { 18 | ~int | ~int32 | ~int64 | ~uint | ~uint32 | ~uint64 19 | } 20 | 21 | type Number interface { 22 | ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 23 | } 24 | 25 | func Base64Encode(str string) string { 26 | return base64.StdEncoding.EncodeToString([]byte(strings.TrimSpace(strings.Trim(str, "\n")))) 27 | } 28 | func Base64Decode(str string) string { 29 | bstr, err := base64.StdEncoding.DecodeString(strings.TrimSpace(strings.Trim(str, "\n"))) 30 | if err != nil { 31 | return str 32 | } 33 | return string(bstr) 34 | } 35 | func Hash(str string) string { 36 | b := sha256.Sum224([]byte(str)) 37 | return hex.EncodeToString(b[:]) 38 | } 39 | 40 | func TimeFormat[T timeNumber](t T) string { 41 | return time.Unix(int64(t), 0).In(global.Tz).Format(time.DateTime) 42 | } 43 | 44 | // 四舍五入保留小数位 45 | func NumberFormat[T ~float32 | ~float64](f T, n ...uint) float64 { 46 | num := uint(2) 47 | if len(n) > 0 { 48 | num = n[0] 49 | } 50 | nu := math.Pow(10, float64(num)) 51 | return math.Round(float64(f)*nu) / nu 52 | } 53 | 54 | // 文件是否存在 55 | func FileExist(path string) bool { 56 | _, err := os.Stat(path) 57 | return err == nil || os.IsExist(err) 58 | } 59 | 60 | // 创建目录 61 | func Mkdir(path string) error { 62 | // 从路径中取目录 63 | dir := filepath.Dir(path) 64 | // 获取信息, 即判断是否存在目录 65 | if _, err := os.Stat(dir); os.IsNotExist(err) { 66 | // 生成目录 67 | if err := os.MkdirAll(dir, os.ModePerm); err != nil { 68 | return err 69 | } 70 | } 71 | return nil 72 | } 73 | 74 | // 创建文件 75 | // 可能存在跨越目录创建文件的风险 76 | func CreateFile(path string) error { 77 | if FileExist(path) { 78 | return nil 79 | } 80 | 81 | if err := Mkdir(path); err != nil { 82 | return err 83 | } 84 | 85 | fi, err := os.Create(path) 86 | if err != nil { 87 | return err 88 | } 89 | defer fi.Close() 90 | 91 | return nil 92 | } 93 | 94 | // 类似php的array_column($a, null, 'key') 95 | func ListToMap(list interface{}, key string) map[string]interface{} { 96 | v := reflect.ValueOf(list) 97 | if v.Kind() != reflect.Slice { 98 | return nil 99 | } 100 | 101 | res := make(map[string]interface{}, v.Len()) 102 | for i := 0; i < v.Len(); i++ { 103 | item := v.Index(i).Interface() 104 | itemValue := reflect.ValueOf(item) 105 | keyValue := itemValue.FieldByName(key) 106 | if keyValue.IsValid() && keyValue.Kind() == reflect.String { 107 | res[keyValue.String()] = item 108 | } 109 | } 110 | 111 | return res 112 | } 113 | 114 | // 判断字符串是否在切片中 115 | func InSlice(slice []string, value string) int { 116 | //上层尽量使用map, 会更快; 117 | 118 | for i, item := range slice { 119 | if item == value { 120 | return i 121 | } 122 | } 123 | return -1 124 | } 125 | 126 | // 判断一个字符串是否包含多个子字符串中的任意一个 127 | func ContainsAny(str string, substrs []string) bool { 128 | for _, substr := range substrs { 129 | if strings.Contains(str, substr) { 130 | return true 131 | } 132 | } 133 | return false 134 | } 135 | 136 | // 取两个切片的交集 137 | func Union[T string | Number](slice1, slice2 []T) []T { 138 | // 创建一个空的哈希集合用于存储第一个切片的元素 139 | set1 := make(map[T]struct{}) 140 | for _, elem := range slice1 { 141 | set1[elem] = struct{}{} 142 | } 143 | 144 | // 创建一个空的哈希集合用于存储交集 145 | intersectionSet := make(map[T]struct{}) 146 | for _, elem := range slice2 { 147 | if _, exists := set1[elem]; exists { 148 | intersectionSet[elem] = struct{}{} 149 | } 150 | } 151 | 152 | // 将交集哈希集合中的所有元素转换为一个切片 153 | result := make([]T, 0, len(intersectionSet)) 154 | for elem := range intersectionSet { 155 | result = append(result, elem) 156 | } 157 | 158 | return result 159 | } 160 | -------------------------------------------------------------------------------- /utils/tool_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/twbworld/proxy/global" 11 | ) 12 | 13 | func TestBase64EncodeDecode(t *testing.T) { 14 | original := "hello world" 15 | encoded := Base64Encode(original) 16 | decoded := Base64Decode(encoded) 17 | assert.Equal(t, original, decoded) 18 | } 19 | 20 | func TestHash(t *testing.T) { 21 | original := "hello world" 22 | hashed := Hash(original) 23 | expected := "2f05477fc24bb4faefd86517156dafdecec45b8ad3cf2522a563582b" 24 | assert.Equal(t, expected, hashed) 25 | } 26 | 27 | func TestTimeFormat(t *testing.T) { 28 | global.Tz, _ = time.LoadLocation("Asia/Shanghai") 29 | timestamp := int64(1700000000) 30 | formatted := TimeFormat(timestamp) 31 | expected := time.Unix(timestamp, 0).In(global.Tz).Format(time.DateTime) 32 | assert.Equal(t, expected, formatted) 33 | } 34 | 35 | func TestNumberFormat(t *testing.T) { 36 | number := 123.456789 37 | formatted := NumberFormat(number, 2) 38 | expected := 123.46 39 | assert.Equal(t, expected, formatted) 40 | } 41 | 42 | func TestFileExist(t *testing.T) { 43 | path := "testfile.txt" 44 | file, err := os.Create(path) 45 | assert.NoError(t, err) 46 | file.Close() 47 | defer os.Remove(path) 48 | assert.True(t, FileExist(path)) 49 | } 50 | 51 | func TestMkdirAndCreateFile(t *testing.T) { 52 | dir := "testdir" 53 | filePath := filepath.Join(dir, "testfile.txt") 54 | defer os.RemoveAll(dir) 55 | 56 | err := Mkdir(filePath) 57 | assert.NoError(t, err) 58 | assert.True(t, FileExist(dir)) 59 | 60 | err = CreateFile(filePath) 61 | assert.NoError(t, err) 62 | assert.True(t, FileExist(filePath)) 63 | } 64 | 65 | func TestListToMap(t *testing.T) { 66 | type Item struct { 67 | Key string 68 | Value string 69 | } 70 | list := []Item{ 71 | {Key: "a", Value: "1"}, 72 | {Key: "b", Value: "2"}, 73 | } 74 | result := ListToMap(list, "Key") 75 | expected := map[string]interface{}{ 76 | "a": Item{Key: "a", Value: "1"}, 77 | "b": Item{Key: "b", Value: "2"}, 78 | } 79 | assert.Equal(t, expected, result) 80 | } 81 | 82 | func TestInSlice(t *testing.T) { 83 | slice := []string{"a", "b", "c"} 84 | assert.Equal(t, 1, InSlice(slice, "b")) 85 | assert.Equal(t, -1, InSlice(slice, "d")) 86 | } 87 | 88 | func TestUnion(t *testing.T) { 89 | // 测试整数切片 90 | intSlice1 := []int{1, 2, 3, 4} 91 | intSlice2 := []int{3, 4, 5, 6} 92 | expectedIntResult := []int{3, 4} 93 | assert.ElementsMatch(t, expectedIntResult, Union(intSlice1, intSlice2)) 94 | 95 | // 测试字符串切片 96 | strSlice1 := []string{"a", "b", "c"} 97 | strSlice2 := []string{"b", "c", "d"} 98 | expectedStrResult := []string{"b", "c"} 99 | assert.ElementsMatch(t, expectedStrResult, Union(strSlice1, strSlice2)) 100 | 101 | // 测试无交集的情况 102 | noIntersectionSlice1 := []int{1, 2} 103 | noIntersectionSlice2 := []int{3, 4} 104 | expectedNoIntersectionResult := []int{} 105 | assert.ElementsMatch(t, expectedNoIntersectionResult, Union(noIntersectionSlice1, noIntersectionSlice2)) 106 | 107 | // 测试空切片的情况 108 | emptySlice := []int{} 109 | expectedEmptyResult := []int{} 110 | assert.ElementsMatch(t, expectedEmptyResult, Union(emptySlice, noIntersectionSlice2)) 111 | assert.ElementsMatch(t, expectedEmptyResult, Union(noIntersectionSlice1, emptySlice)) 112 | assert.ElementsMatch(t, expectedEmptyResult, Union(emptySlice, emptySlice)) 113 | } 114 | --------------------------------------------------------------------------------