Repository: bestruirui/mihomo-check Branch: dev Commit: b941667ba5c2 Files: 322 Total size: 970.3 KB Directory structure: gitextract_4ho2v_lh/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ └── workflows/ │ ├── changelog.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── cmd/ │ └── bestsub/ │ └── main.go ├── deploy/ │ ├── README.md │ └── docker-compose.yaml ├── docs/ │ ├── api/ │ │ └── swagger.json │ ├── database/ │ │ ├── BESTSUB.json │ │ └── README.md │ └── rename/ │ └── README.md ├── go.mod ├── go.sum ├── internal/ │ ├── config/ │ │ └── base.go │ ├── core/ │ │ ├── check/ │ │ │ ├── check.go │ │ │ └── checker/ │ │ │ ├── alive.go │ │ │ ├── country.go │ │ │ ├── speed.go │ │ │ └── tiktok.go │ │ ├── cron/ │ │ │ ├── check.go │ │ │ ├── cron.go │ │ │ └── fetch.go │ │ ├── fetch/ │ │ │ └── fetch.go │ │ ├── mihomo/ │ │ │ └── mihomo.go │ │ ├── node/ │ │ │ ├── exist.go │ │ │ ├── info.go │ │ │ ├── node.go │ │ │ └── var.go │ │ ├── subconv/ │ │ │ └── subconv.go │ │ ├── system/ │ │ │ └── monitor.go │ │ ├── task/ │ │ │ └── task.go │ │ └── update/ │ │ ├── core.go │ │ └── update.go │ ├── database/ │ │ ├── client/ │ │ │ └── sqlite/ │ │ │ ├── auth.go │ │ │ ├── check.go │ │ │ ├── migration/ │ │ │ │ ├── 001_table.go │ │ │ │ ├── 002_add_sub_tags.go │ │ │ │ └── migration.go │ │ │ ├── migrator.go │ │ │ ├── notify.go │ │ │ ├── setting.go │ │ │ ├── share.go │ │ │ ├── sqlite.go │ │ │ ├── storage.go │ │ │ └── sub.go │ │ ├── database.go │ │ ├── init.go │ │ ├── interfaces/ │ │ │ ├── auth.go │ │ │ ├── check.go │ │ │ ├── notify.go │ │ │ ├── repository.go │ │ │ ├── setting.go │ │ │ ├── share.go │ │ │ ├── storage.go │ │ │ └── sub.go │ │ ├── migration/ │ │ │ └── migration.go │ │ └── op/ │ │ ├── auth.go │ │ ├── check.go │ │ ├── notify.go │ │ ├── repo.go │ │ ├── setting.go │ │ ├── share.go │ │ ├── storage.go │ │ └── sub.go │ ├── models/ │ │ ├── auth/ │ │ │ ├── auth.go │ │ │ └── default.go │ │ ├── check/ │ │ │ └── check.go │ │ ├── common/ │ │ │ └── base.go │ │ ├── config/ │ │ │ ├── config.go │ │ │ └── default.go │ │ ├── node/ │ │ │ └── node.go │ │ ├── notify/ │ │ │ ├── default.go │ │ │ └── notify.go │ │ ├── setting/ │ │ │ ├── default.go │ │ │ └── setting.go │ │ ├── share/ │ │ │ └── share.go │ │ ├── storage/ │ │ │ └── storage.go │ │ ├── sub/ │ │ │ └── sub.go │ │ └── system/ │ │ └── info.go │ ├── modules/ │ │ ├── country/ │ │ │ ├── channel/ │ │ │ │ ├── cloudflare.go │ │ │ │ ├── commen.go │ │ │ │ ├── freeip.go │ │ │ │ ├── ip_sb.go │ │ │ │ ├── ipapi.go │ │ │ │ ├── ipwho.go │ │ │ │ ├── myip.go │ │ │ │ ├── reallyfreegeoip.go │ │ │ │ └── register.go │ │ │ └── country.go │ │ ├── notify/ │ │ │ ├── channel/ │ │ │ │ ├── email.go │ │ │ │ └── webhook.go │ │ │ └── notify.go │ │ ├── register/ │ │ │ ├── category.go │ │ │ └── register.go │ │ ├── share/ │ │ │ └── share.go │ │ └── storage/ │ │ ├── channel/ │ │ │ └── webdav.go │ │ └── storage.go │ ├── server/ │ │ ├── auth/ │ │ │ └── auth.go │ │ ├── handlers/ │ │ │ ├── auth.go │ │ │ ├── check.go │ │ │ ├── info.go │ │ │ ├── log.go │ │ │ ├── notify.go │ │ │ ├── pprof.go │ │ │ ├── scalar.go │ │ │ ├── setting.go │ │ │ ├── share.go │ │ │ ├── storage.go │ │ │ ├── sub.go │ │ │ ├── update.go │ │ │ └── ws.go │ │ ├── middleware/ │ │ │ ├── auth.go │ │ │ ├── cors.go │ │ │ ├── logging.go │ │ │ ├── recovery.go │ │ │ └── static.go │ │ ├── resp/ │ │ │ └── resp.go │ │ ├── router/ │ │ │ └── router.go │ │ └── server/ │ │ └── server.go │ └── utils/ │ ├── cache/ │ │ ├── cache.go │ │ └── shard.go │ ├── color/ │ │ └── color.go │ ├── country/ │ │ └── conutry.go │ ├── desc/ │ │ └── desc.go │ ├── generic/ │ │ ├── map.go │ │ └── queue.go │ ├── info/ │ │ └── info.go │ ├── log/ │ │ └── log.go │ ├── shutdown/ │ │ └── shutdown.go │ ├── ua/ │ │ └── ua.go │ └── utils.go ├── scripts/ │ ├── build.sh │ └── dockerfiles/ │ ├── Dockerfile.alpine │ ├── Dockerfile.debian │ └── entrypoint.sh ├── static/ │ └── static.go └── web/ ├── .env.example ├── .gitignore ├── components.json ├── eslint.config.mjs ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── src/ │ ├── app/ │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── not-found.tsx │ │ └── page.tsx │ ├── components/ │ │ ├── app/ │ │ │ ├── app-layout.tsx │ │ │ ├── index.ts │ │ │ └── spa-app.tsx │ │ ├── features/ │ │ │ ├── check/ │ │ │ │ ├── components/ │ │ │ │ │ ├── check-form.tsx │ │ │ │ │ ├── check-list.tsx │ │ │ │ │ ├── check-page.tsx │ │ │ │ │ ├── form-sections/ │ │ │ │ │ │ ├── basic-config-section.tsx │ │ │ │ │ │ ├── basic-info-section.tsx │ │ │ │ │ │ ├── extra-config-section.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── log-config.tsx │ │ │ │ │ │ └── notify-config.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── constants/ │ │ │ │ │ └── index.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── useCheckForm.ts │ │ │ │ ├── index.ts │ │ │ │ └── utils/ │ │ │ │ └── index.ts │ │ │ ├── home/ │ │ │ │ └── dashboard.tsx │ │ │ ├── index.ts │ │ │ ├── login/ │ │ │ │ ├── index.ts │ │ │ │ ├── login-form.tsx │ │ │ │ └── login-page.tsx │ │ │ ├── notify/ │ │ │ │ ├── components/ │ │ │ │ │ ├── notify-form.tsx │ │ │ │ │ ├── notify-list.tsx │ │ │ │ │ └── notify-page.tsx │ │ │ │ ├── hooks/ │ │ │ │ │ ├── useNotifyForm.ts │ │ │ │ │ └── useNotifyOperations.ts │ │ │ │ └── index.ts │ │ │ ├── profile/ │ │ │ │ ├── ProfileDesktopNavButton.tsx │ │ │ │ ├── ProfileDialog.tsx │ │ │ │ ├── ProfileLayout.tsx │ │ │ │ ├── ProfileNavButton.tsx │ │ │ │ └── index.ts │ │ │ ├── settings/ │ │ │ │ ├── SettingsActions.tsx │ │ │ │ ├── SettingsLayout.tsx │ │ │ │ ├── sections/ │ │ │ │ │ ├── NodeSettingsSection.tsx │ │ │ │ │ ├── NotifySettingsSection.tsx │ │ │ │ │ ├── SystemSettingsSection.tsx │ │ │ │ │ ├── TaskSettingsSection.tsx │ │ │ │ │ ├── fields/ │ │ │ │ │ │ ├── BooleanSettingField.tsx │ │ │ │ │ │ ├── MultiSelectSettingField.tsx │ │ │ │ │ │ ├── NumberSettingField.tsx │ │ │ │ │ │ ├── SelectSettingField.tsx │ │ │ │ │ │ ├── SettingCard.tsx │ │ │ │ │ │ ├── TextSettingField.tsx │ │ │ │ │ │ └── types.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── settings.tsx │ │ │ │ └── utils/ │ │ │ │ └── value-mappers.ts │ │ │ ├── share/ │ │ │ │ ├── components/ │ │ │ │ │ ├── form-sections/ │ │ │ │ │ │ ├── alive-status-section.tsx │ │ │ │ │ │ ├── basic-info-section.tsx │ │ │ │ │ │ ├── config-section.tsx │ │ │ │ │ │ ├── country-section.tsx │ │ │ │ │ │ ├── filter-section.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── share-copy.tsx │ │ │ │ │ ├── share-date-pick.tsx │ │ │ │ │ ├── share-form.tsx │ │ │ │ │ ├── share-list.tsx │ │ │ │ │ └── share-page.tsx │ │ │ │ ├── constants/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── sub-rules.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── useShareForm.ts │ │ │ │ │ └── useShareOperations.ts │ │ │ │ ├── index.ts │ │ │ │ └── utils/ │ │ │ │ └── index.ts │ │ │ ├── storage/ │ │ │ │ └── storage.tsx │ │ │ ├── sub/ │ │ │ │ ├── components/ │ │ │ │ │ ├── batch-sub-form.tsx │ │ │ │ │ ├── form-sections/ │ │ │ │ │ │ ├── basic-info-section.tsx │ │ │ │ │ │ ├── config-section.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── protocol-filter-section.tsx │ │ │ │ │ ├── sub-detail.tsx │ │ │ │ │ ├── sub-form.tsx │ │ │ │ │ ├── sub-list.tsx │ │ │ │ │ └── sub-page.tsx │ │ │ │ ├── index.ts │ │ │ │ └── utils/ │ │ │ │ └── index.ts │ │ │ └── system-update/ │ │ │ └── index.tsx │ │ ├── layout/ │ │ │ ├── app-sidebar.tsx │ │ │ ├── index.ts │ │ │ ├── nav-documents.tsx │ │ │ ├── nav-main.tsx │ │ │ ├── nav-secondary.tsx │ │ │ ├── nav-user.tsx │ │ │ └── site-header.tsx │ │ ├── pages/ │ │ │ ├── index.ts │ │ │ └── not-found.tsx │ │ ├── providers/ │ │ │ ├── alert-provider.tsx │ │ │ ├── auth-provider.tsx │ │ │ ├── index.ts │ │ │ ├── query-provider.tsx │ │ │ └── theme-provider.tsx │ │ ├── shared/ │ │ │ ├── dynamic-config-form.tsx │ │ │ ├── status-badge.tsx │ │ │ └── subscription-section.tsx │ │ └── ui/ │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── hover-card.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── loading.tsx │ │ ├── mode-toggle.tsx │ │ ├── popover.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ └── tooltip.tsx │ ├── constant/ │ │ ├── protocols.ts │ │ └── settings-keys.ts │ ├── lib/ │ │ ├── api/ │ │ │ ├── client.ts │ │ │ └── token-manager.ts │ │ ├── config/ │ │ │ ├── config.ts │ │ │ └── version.ts │ │ ├── hooks/ │ │ │ ├── index.ts │ │ │ ├── use-form-update.ts │ │ │ ├── use-form.ts │ │ │ ├── use-mobile.ts │ │ │ └── useOverflowDetection.ts │ │ └── queries/ │ │ ├── check-queries.ts │ │ ├── index.ts │ │ ├── setting-queries.ts │ │ ├── share-queries.ts │ │ └── sub-queries.ts │ ├── router/ │ │ ├── core/ │ │ │ ├── context.tsx │ │ │ ├── outlet.tsx │ │ │ └── router.tsx │ │ ├── hooks/ │ │ │ ├── use-navigation.tsx │ │ │ ├── use-route-preloader.tsx │ │ │ └── use-route-title.tsx │ │ ├── index.ts │ │ └── routes.tsx │ ├── types/ │ │ ├── api.ts │ │ ├── auth.ts │ │ ├── check.ts │ │ ├── common.ts │ │ ├── index.ts │ │ ├── notify.ts │ │ ├── setting.ts │ │ ├── share.ts │ │ ├── sub.ts │ │ └── update.ts │ └── utils/ │ ├── cron.ts │ ├── format.ts │ ├── index.ts │ ├── time.ts │ ├── url.ts │ └── validation.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 错误反馈 description: "提交错误反馈" title: "[Bug] " labels: ["bug"] body: - type: checkboxes id: ensure attributes: label: 验证步骤 description: 在提交之前,请勾选以下选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。 options: - label: 我已在 [Issue](https://github.com/bestruirui/BestSub/issues) 中寻找过我要提出的问题,并且没有找到 required: true - type: textarea attributes: label: 描述 description: 请提供错误的详细描述。 validations: required: true - type: textarea attributes: label: 重现方式 description: 请提供重现错误的步骤 validations: required: true - type: textarea attributes: label: 日志 description: 在下方附上运行日志 render: shell ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: 功能请求 description: 为该项目提出建议 title: "[Feature] " labels: ["enhancement"] body: - type: checkboxes id: ensure attributes: label: 验证步骤 description: 在提交之前,请勾选以下选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。 options: - label: 我已经阅读了 [README.md](https://github.com/bestruirui/BestSub/blob/master/README.md),确认了该功能没有实现 required: true - label: 我已在 [Issue](https://github.com/bestruirui/BestSub/issues) 中寻找过我要提出的功能请求,并且没有找到 required: true - type: textarea attributes: label: 描述 description: 请提供对于该功能的详细描述,而不是莫名其妙的话术。 validations: required: true ================================================ FILE: .github/workflows/changelog.yml ================================================ name: changelog on: push: tags: - 'v*' permissions: contents: write jobs: changelog: name: Create Release runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 0 token: ${{ secrets.ACTION_TOKEN }} - run: npx changelogithub env: GITHUB_TOKEN: ${{secrets.ACTION_TOKEN}} - name: Merge dev to master branch run: | git config --global user.name 'GitHub Actions' git config --global user.email 'github-actions@github.com' if ! git show-ref --verify --quiet refs/heads/master; then git checkout -b master else git checkout master fi git fetch origin dev git merge origin/dev git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }} git push origin master --force env: GITHUB_TOKEN: ${{secrets.ACTION_TOKEN}} ================================================ FILE: .github/workflows/release.yml ================================================ name: release on: push: branches: - master permissions: contents: write packages: write jobs: release: name: release runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v5 with: fetch-depth: 0 ref: master - name: Cache toolchains uses: actions/cache@v4 with: path: ~/.bestsub/toolchains key: ${{ runner.os }}-toolchains-${{ hashFiles('go.mod') }}-${{ hashFiles('scripts/build.sh') }} - name: Setup Go uses: actions/setup-go@v5 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 'latest' - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 'latest' cache: 'pnpm' cache-dependency-path: web/pnpm-lock.yaml - name: Build run: bash scripts/build.sh release - name: Get latest tag id: tag run: | LATEST_TAG=$(git describe --tags --abbrev=0) echo "TAG_NAME=$LATEST_TAG" >> $GITHUB_OUTPUT - name: Upload Release uses: softprops/action-gh-release@v2 with: files: build/archives/* prerelease: false tag_name: ${{ steps.tag.outputs.TAG_NAME }} - name: Docker meta (Debian) id: meta-debian uses: docker/metadata-action@v5 with: images: | ghcr.io/${{ github.repository }} ${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }} tags: | type=raw,value=latest type=raw,value=${{ steps.tag.outputs.TAG_NAME }} - name: Docker meta (Alpine) id: meta-alpine uses: docker/metadata-action@v5 with: images: | ghcr.io/${{ github.repository }} ${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }} tags: | type=raw,value=latest-alpine type=raw,value=${{ steps.tag.outputs.TAG_NAME }}-alpine - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push (Alpine) uses: docker/build-push-action@v5 with: context: . file: ./scripts/dockerfiles/Dockerfile.alpine push: true platforms: linux/amd64,linux/i386,linux/arm64,linux/arm/v7 tags: ${{ steps.meta-alpine.outputs.tags }} labels: ${{ steps.meta-alpine.outputs.labels }} build-args: | TARGETPLATFORM - name: Build and push (Debian) uses: docker/build-push-action@v5 with: context: . file: ./scripts/dockerfiles/Dockerfile.debian push: true platforms: linux/amd64,linux/i386,linux/arm64,linux/arm/v7 tags: ${{ steps.meta-debian.outputs.tags }} labels: ${{ steps.meta-debian.outputs.labels }} build-args: | TARGETPLATFORM ================================================ FILE: .gitignore ================================================ # web static/out/* !static/out/README.md # api api/* !api/README.md # build dist/* build # vscode .vscode # data data internal/core/subconv/subconv.es5.js ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ # BestSub BestSub 是一个高性能的节点检测,订阅转换服务,基于 Go 语言开发。该项目提供了完整的 Web 界面和 API 接口,支持多种检测项目,多种订阅格式转换,为用户提供便捷的订阅管理解决方案。 ## ✨ 主要特性 - 🎨 **现代的 WebUI**: 提供现代化的 Web 管理界面,完善的 API 文档,方便用户自定义开发 - ⚡ **高性能**: 并发处理,低 CPU 占用,低内存消耗,优化的资源利用率 - 🎲 **分享**: 高度自定义的分享功能,自定义节点名称,过期时间,最大访问量,节点类型,国家.... - 🌍 **多架构**: 支持多种系统架构和操作系统,广泛的兼容性 - 🗂️ **节点池**: 可持久化保存历史节点,智能淘汰质量低下的节点,确保最佳体验 - 🔧 **扩展**: 模块化设计,支持 PR 扩展新功能,仅需创建单个文件即可添加新的通知、保存或检测方式 - 📢 **通知**: 支持多样化的通知方式和自定义通知模板,满足不同场景的消息推送需求 - 💾 **保存**: 支持多样化的数据保存方式,灵活的数据持久化选择 - 🔍 **检测**: 支持多样化的节点检测方式,全面的质量评估体系 ## 🚀 快速开始 ### 方式一:直接运行 1. 从 [Releases](https://github.com/bestruirui/BestSub/releases/latest) 页面下载适合您系统架构的可执行文件 2. 直接运行程序,系统将自动: - 创建必要的配置文件 ### 方式二:Docker ```bash docker run -d \ --name bestsub \ -e PUID=1000 \ -e PGID=1000 \ --restart unless-stopped \ -v /path/to/data:/app/data \ -p 8080:8080 \ ghcr.io/bestruirui/bestsub ``` **参数说明:** - `--name bestsub`: 设置容器名称 - `--restart unless-stopped`: 容器自动重启策略 - `-v /path/to/data:/app/data`: 数据持久化挂载(请将 `/path/to/data` 替换为您的实际路径) - `-p 8080:8080`: 端口映射,访问地址为 `http://localhost:8080` ### 方式三:Docker Compose 创建 `docker-compose.yml` 文件: ```yaml services: bestsub: image: ghcr.io/bestruirui/bestsub:latest container_name: bestsub restart: unless-stopped environment: - PUID=1000 - PGID=1000 ports: - "8080:8080" volumes: - ./data:/app/data minisubconvert: image: ghcr.io/bestruirui/minisubconvert:latest container_name: minisubconvert restart: unless-stopped environment: - PUID=1000 - PGID=1000 ports: - "3000:3000" ``` 启动服务: ```bash docker-compose up -d ``` ## 📁 目录结构 程序运行后将创建以下目录结构: ``` bestsub/ ├── config.json # 主配置文件 ├── data/ # 数据目录 │ └── bestsub.db # SQLite 数据库文件 ├── log/ # 日志文件目录 ├── session/ # 会话数据目录 │ └── bestsub.session # 会话文件 ``` ## 🔗 版本历史 ### 当前版本 (v1.x) - 全新的 Web 界面 - 增强的性能和稳定性 - 完整的容器化支持 ### 经典版本 (v0.3.5) - **命令行界面版本** - **[📖 查看文档](https://github.com/bestruirui/BestSub/blob/legacy/doc/README_zh.md)** - **[⬇️ 下载应用](https://github.com/bestruirui/BestSub/releases/tag/v0.3.5)** ## 📋 版本规范 ### 版本格式 版本号采用语义化版本格式:**`vX.Y.Z`** - **`X`** (主版本号 - Major) - **`Y`** (次版本号 - Minor) - **`Z`** (修订版本号 - Patch) ### 版本变更规则 **🔢 主版本号 (X - Major)** - 主版本号增加表示**重大更新** - 包含**破坏性变更 (Breaking Changes)**,如: - 数据结构、API 接口的不兼容性修改 - 重大的架构调整或重构 - 当主版本号增加时,次版本号和修订版本号归零 - 示例:`v1.5.3` → `v2.0.0` **⚡ 次版本号 (Y - Minor)** - 次版本号增加表示**功能更新** - 包含向后兼容的功能性新增或增强 - **前后端版本号在此位必须保持一致**,确保功能正常调用和兼容 - 当次版本号增加时,修订版本号归零 - 示例:`v1.2.8` → `v1.3.0` **🔧 修订版本号 (Z - Patch)** - 修订版本号增加表示**问题修复**或微小优化 - 用于修复向后兼容的 Bug 或进行小幅优化调整 - 此版本更新不要求前后端严格同步,但建议保持一致 - 示例:`v1.3.0` → `v1.3.1` ## 🤝 贡献指南 我们欢迎任何形式的贡献! ### 项目图标 - **格式要求**: SVG 格式 - **用途**: 项目 Logo 和品牌标识 - **提交方式**: 创建 Issue 或 Pull Request ### 更多功能 - 新的节点检测项目 - 新的储存渠道 - 新的通知渠道 ### 其他贡献方式 - 🐛 报告 Bug - 💡 提出新功能建议 - 📝 改进文档 - 🧪 编写测试用例 ## ⚠️ 免责声明 本项目仅供学习和研究使用。使用本软件时,请您: - ✅ 遵守当地法律法规和相关政策 - ✅ 尊重网络服务提供商的使用条款 - ✅ 承担使用本软件可能产生的一切后果和责任 - ⚠️ 理解作者不对使用本软件造成的任何损失承担责任 **请在合法合规的前提下使用本软件。如果您不同意上述条款,请勿使用本软件。** ## ❤️ 支持项目 如果这个项目对您有帮助,请考虑: - ⭐ 给项目点个 Star - 🍴 Fork 项目并参与开发 - 📢 向朋友推荐本项目 - 💬 在社区中分享使用体验 ## 📊 项目统计 ![Repobeats analytics image](https://repobeats.axiom.co/api/embed/dfefb13ae0ed117da68382c0ed63695992826039.svg "Repobeats analytics image") ================================================ FILE: cmd/bestsub/main.go ================================================ package main import ( "github.com/bestruirui/bestsub/internal/config" "github.com/bestruirui/bestsub/internal/core/cron" "github.com/bestruirui/bestsub/internal/core/node" "github.com/bestruirui/bestsub/internal/core/task" "github.com/bestruirui/bestsub/internal/database" "github.com/bestruirui/bestsub/internal/database/op" "github.com/bestruirui/bestsub/internal/models/setting" "github.com/bestruirui/bestsub/internal/server/server" "github.com/bestruirui/bestsub/internal/utils/info" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/bestruirui/bestsub/internal/utils/shutdown" ) func main() { info.Banner() cfg := config.Base() if err := log.Initialize(cfg.Log.Level, cfg.Log.Path, cfg.Log.Output); err != nil { panic(err) } if err := database.Initialize(cfg.Database.Type, cfg.Database.Path); err != nil { panic(err) } if err := server.Initialize(); err != nil { panic(err) } task.Init(op.GetSettingInt(setting.TASK_MAX_THREAD)) cron.Start() cron.FetchLoad() cron.CheckLoad() node.InitNodePool(op.GetSettingInt(setting.NODE_POOL_SIZE)) log.CleanupOldLogs(5) server.Start() shutdown.Register(server.Close) // ↓↓ shutdown.Register(database.Close) // ↓↓ shutdown.Register(node.CloseNodePool) // ↓↓ shutdown.Register(log.Close) // ↓↓ shutdown.Listen() } ================================================ FILE: deploy/README.md ================================================ # Depoly ================================================ FILE: deploy/docker-compose.yaml ================================================ services: bestsub: image: ghcr.io/bestruirui/bestsub:latest container_name: bestsub restart: always ports: - '8080:8080' volumes: - './bestsub:/app/data' ================================================ FILE: docs/api/swagger.json ================================================ { "swagger": "2.0", "info": { "description": "BestSub - API 文档\n\n这是 BestSub 的 API 文档\n\n## 认证\n大多数接口需要使用 JWT 令牌进行认证。\n认证时,请在 Authorization 头中包含 JWT 令牌:\n`Authorization: Bearer \u003cyour-jwt-token\u003e`\n\n## 错误响应\n所有错误响应都遵循统一格式,包含 code、message 和 error 字段。\n\n## 成功响应\n所有成功响应都遵循统一格式,包含 code、message 和 data 字段。", "title": "BestSub API", "contact": { "name": "BestSub API 支持", "email": "support@bestsub.com" }, "license": { "name": "GPL-3.0", "url": "https://opensource.org/license/gpl-3-0" }, "version": "1.0.0" }, "paths": { "/api/v1/auth/login": { "post": { "description": "用户登录接口,验证用户名和密码,返回JWT令牌", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "认证" ], "summary": "用户登录", "parameters": [ { "description": "登录请求", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_auth.LoginRequest" } } ], "responses": { "200": { "description": "登录成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_auth.LoginResponse" } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "用户名或密码错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/auth/logout": { "post": { "security": [ { "BearerAuth": [] } ], "description": "用户登出接口,使当前会话失效", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "认证" ], "summary": "用户登出", "responses": { "200": { "description": "登出成功", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/auth/user": { "get": { "security": [ { "BearerAuth": [] } ], "description": "获取当前登录用户的详细信息", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "认证" ], "summary": "获取用户信息", "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_auth.Data" } } } ] } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/auth/user/name": { "post": { "security": [ { "BearerAuth": [] } ], "description": "修改当前用户的用户名", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "认证" ], "summary": "修改用户名", "parameters": [ { "description": "修改用户名请求", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_auth.UpdateUserInfoRequest" } } ], "responses": { "200": { "description": "用户名修改成功", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "409": { "description": "用户名已存在", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/auth/user/password": { "post": { "security": [ { "BearerAuth": [] } ], "description": "修改当前用户的密码", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "认证" ], "summary": "修改密码", "parameters": [ { "description": "修改密码请求", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_auth.ChangePasswordRequest" } } ], "responses": { "200": { "description": "密码修改成功", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权或旧密码错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/check": { "get": { "security": [ { "BearerAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "检测" ], "summary": "获取检测列表", "parameters": [ { "type": "integer", "description": "检测ID", "name": "id", "in": "query", "required": true } ], "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_check.Response" } } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } }, "post": { "security": [ { "BearerAuth": [] } ], "description": "创建单个检测", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "检测" ], "summary": "创建检测", "parameters": [ { "description": "创建检测请求", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_check.Request" } } ], "responses": { "200": { "description": "创建成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_check.Response" } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/check/type": { "get": { "security": [ { "BearerAuth": [] } ], "description": "获取检测类型", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "检测" ], "summary": "获取检测类型", "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "object", "additionalProperties": { "type": "array", "items": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_core_check.Desc" } } } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/check/{id}": { "put": { "security": [ { "BearerAuth": [] } ], "description": "根据请求体中的ID更新检测信息", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "检测" ], "summary": "更新检测", "parameters": [ { "type": "integer", "description": "检测ID", "name": "id", "in": "path", "required": true }, { "description": "更新检测请求", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_check.Request" } } ], "responses": { "200": { "description": "更新成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_check.Response" } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "404": { "description": "检测不存在", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } }, "delete": { "security": [ { "BearerAuth": [] } ], "description": "根据ID删除单个检测", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "检测" ], "summary": "删除检测", "parameters": [ { "type": "integer", "description": "检测ID", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "删除成功", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "404": { "description": "检测不存在", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/check/{id}/run": { "post": { "security": [ { "BearerAuth": [] } ], "description": "手动触发检测执行", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "检测" ], "summary": "手动运行检测", "parameters": [ { "type": "integer", "description": "检测ID", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "运行成功", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "404": { "description": "检测不存在", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/check/{id}/stop": { "post": { "security": [ { "BearerAuth": [] } ], "description": "停止正在运行的检测", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "检测" ], "summary": "停止检测", "parameters": [ { "type": "integer", "description": "检测ID", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "停止成功", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "404": { "description": "检测不存在", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/log/content": { "get": { "security": [ { "BearerAuth": [] } ], "description": "获取日志内容", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "日志" ], "summary": "获取日志内容", "parameters": [ { "type": "string", "description": "日志文件路径", "name": "path", "in": "query", "required": true }, { "type": "integer", "format": "int64", "description": "日志文件时间戳", "name": "timestamp", "in": "query", "required": true } ], "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "array", "items": { "type": "object", "properties": { "level": { "type": "string" }, "msg": { "type": "string" }, "time": { "type": "string" } } } } } } ] } }, "400": { "description": "参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "404": { "description": "文件不存在", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/log/list": { "get": { "security": [ { "BearerAuth": [] } ], "description": "获取日志列表", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "日志" ], "summary": "获取日志列表", "parameters": [ { "type": "string", "description": "日志文件路径", "name": "path", "in": "query", "required": true } ], "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "array", "items": { "type": "integer", "format": "int64" } } } } ] } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/notify": { "get": { "security": [ { "BearerAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "通知" ], "summary": "获取通知", "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_notify.Response" } } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } }, "put": { "security": [ { "BearerAuth": [] } ], "description": "根据请求体中的ID更新通知信息", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "通知" ], "summary": "更新通知", "parameters": [ { "type": "integer", "description": "通知ID", "name": "id", "in": "query", "required": true }, { "description": "更新通知请求", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_notify.Request" } } ], "responses": { "200": { "description": "更新成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_notify.Response" } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "404": { "description": "通知配置不存在", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } }, "post": { "security": [ { "BearerAuth": [] } ], "description": "创建单个通知", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "通知" ], "summary": "创建通知", "parameters": [ { "description": "创建通知请求", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_notify.Request" } } ], "responses": { "200": { "description": "创建成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_notify.Response" } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } }, "delete": { "security": [ { "BearerAuth": [] } ], "description": "根据ID删除单个通知", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "通知" ], "summary": "删除通知", "parameters": [ { "type": "integer", "description": "通知ID", "name": "id", "in": "query", "required": true } ], "responses": { "200": { "description": "删除成功", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "404": { "description": "通知不存在", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/notify/channel": { "get": { "security": [ { "BearerAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "通知" ], "summary": "获取通知渠道", "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "array", "items": { "type": "string" } } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/notify/channel/config": { "get": { "security": [ { "BearerAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "通知" ], "summary": "获取渠道配置", "parameters": [ { "type": "string", "description": "渠道", "name": "channel", "in": "query" } ], "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "object", "additionalProperties": { "type": "array", "items": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_modules_notify.Desc" } } } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/notify/name": { "get": { "security": [ { "BearerAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "通知" ], "summary": "获取通知名称和ID", "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_notify.NameAndID" } } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/notify/template": { "get": { "security": [ { "BearerAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "通知" ], "summary": "获取通知模板", "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_notify.Template" } } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } }, "put": { "security": [ { "BearerAuth": [] } ], "description": "根据请求体中的ID更新通知模板信息", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "通知" ], "summary": "更新通知模板", "parameters": [ { "description": "更新通知模板请求", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_notify.Template" } } ], "responses": { "200": { "description": "更新成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_notify.Template" } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "404": { "description": "通知模板不存在", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/notify/test": { "post": { "security": [ { "BearerAuth": [] } ], "description": "测试单个通知", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "通知" ], "summary": "测试通知", "parameters": [ { "description": "测试通知请求", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_notify.Request" } } ], "responses": { "200": { "description": "测试成功", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/setting": { "get": { "security": [ { "BearerAuth": [] } ], "description": "获取系统所有配置项,支持按分组过滤和关键字搜索", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "配置" ], "summary": "获取配置项", "parameters": [ { "type": "string", "description": "分组名称", "name": "group", "in": "query" } ], "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_setting.Setting" } } } } ] } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } }, "put": { "security": [ { "BearerAuth": [] } ], "description": "根据请求数据中的ID批量更新配置项的值和描述", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "配置" ], "summary": "更新配置项", "parameters": [ { "description": "更新配置项请求", "name": "request", "in": "body", "required": true, "schema": { "type": "array", "items": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_setting.Setting" } } } ], "responses": { "200": { "description": "更新成功", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/share": { "get": { "security": [ { "BearerAuth": [] } ], "description": "获取分享链接", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "分享" ], "summary": "获取分享链接", "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_share.Response" } } } } ] } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } }, "post": { "security": [ { "BearerAuth": [] } ], "description": "创建分享链接", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "分享" ], "summary": "创建分享链接", "parameters": [ { "description": "分享数据", "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_share.Request" } } ], "responses": { "200": { "description": "创建成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_share.Response" } } } ] } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/share/node/{token}": { "get": { "description": "获取订阅内容 纯Mihomo格式的节点", "consumes": [ "application/json" ], "produces": [ "text/plain" ], "tags": [ "分享" ], "summary": "获取订阅内容 纯Mihomo格式的节点", "parameters": [ { "type": "string", "description": "分享token", "name": "token", "in": "path", "required": true } ], "responses": { "200": { "description": "获取成功,内容为yaml/plain格式", "schema": { "type": "string" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/share/sub/{token}": { "get": { "description": "获取订阅内容 带规则的订阅", "consumes": [ "application/json" ], "produces": [ "text/plain" ], "tags": [ "分享" ], "summary": "获取订阅内容 带规则的订阅", "parameters": [ { "type": "string", "description": "分享token", "name": "token", "in": "path", "required": true } ], "responses": { "200": { "description": "获取成功,内容为yaml/plain格式", "schema": { "type": "string" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/share/{id}": { "put": { "security": [ { "BearerAuth": [] } ], "description": "更新分享链接", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "分享" ], "summary": "更新分享链接", "parameters": [ { "type": "string", "description": "分享ID", "name": "id", "in": "path", "required": true }, { "description": "分享数据", "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_share.Request" } } ], "responses": { "200": { "description": "更新成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_share.Response" } } } ] } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } }, "delete": { "security": [ { "BearerAuth": [] } ], "description": "删除分享链接", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "分享" ], "summary": "删除分享链接", "parameters": [ { "type": "string", "description": "分享ID", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "删除成功", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/storage": { "get": { "security": [ { "BearerAuth": [] } ], "description": "获取存储", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "存储" ], "summary": "获取存储", "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_storage.Response" } } } } ] } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } }, "post": { "security": [ { "BearerAuth": [] } ], "description": "创建存储", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "存储" ], "summary": "创建存储", "parameters": [ { "description": "存储配置数据", "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_storage.Request" } } ], "responses": { "200": { "description": "创建成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_storage.Response" } } } ] } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/storage/channel": { "get": { "security": [ { "BearerAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "存储" ], "summary": "获取存储渠道", "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "array", "items": { "type": "string" } } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/storage/channel/config": { "get": { "security": [ { "BearerAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "存储" ], "summary": "获取渠道配置", "parameters": [ { "type": "string", "description": "渠道", "name": "channel", "in": "query" } ], "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "object", "additionalProperties": { "type": "array", "items": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_modules_storage.Desc" } } } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/storage/{id}": { "put": { "security": [ { "BearerAuth": [] } ], "description": "更新存储", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "存储" ], "summary": "更新存储", "parameters": [ { "type": "string", "description": "存储ID", "name": "id", "in": "path", "required": true }, { "description": "存储配置数据", "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_storage.Request" } } ], "responses": { "200": { "description": "更新成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_storage.Response" } } } ] } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } }, "delete": { "security": [ { "BearerAuth": [] } ], "description": "删除存储", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "存储" ], "summary": "删除存储", "parameters": [ { "type": "string", "description": "存储ID", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "删除成功", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/sub": { "get": { "security": [ { "BearerAuth": [] } ], "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "订阅" ], "summary": "获取订阅链接", "parameters": [ { "type": "integer", "description": "链接ID", "name": "id", "in": "query", "required": true } ], "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_sub.Response" } } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } }, "post": { "security": [ { "BearerAuth": [] } ], "description": "创建单个订阅链接", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "订阅" ], "summary": "创建订阅链接", "parameters": [ { "description": "创建订阅链接请求", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_sub.Request" } } ], "responses": { "200": { "description": "创建成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_sub.Response" } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/sub/batch": { "post": { "security": [ { "BearerAuth": [] } ], "description": "批量创建多个订阅链接", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "订阅" ], "summary": "批量创建订阅链接", "parameters": [ { "description": "批量创建订阅链接请求", "name": "request", "in": "body", "required": true, "schema": { "type": "array", "items": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_sub.Request" } } } ], "responses": { "200": { "description": "创建成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_sub.Response" } } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/sub/refresh/{id}": { "post": { "security": [ { "BearerAuth": [] } ], "description": "根据ID手动刷新单个订阅", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "订阅" ], "summary": "手动刷新订阅", "parameters": [ { "type": "integer", "description": "订阅链接ID", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "刷新成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_sub.Result" } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "404": { "description": "订阅链接不存在", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/sub/{id}": { "put": { "security": [ { "BearerAuth": [] } ], "description": "根据请求体中的ID更新订阅链接信息", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "订阅" ], "summary": "更新订阅链接", "parameters": [ { "type": "integer", "description": "订阅链接ID", "name": "id", "in": "path", "required": true }, { "description": "更新订阅链接请求", "name": "request", "in": "body", "required": true, "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_sub.Request" } } ], "responses": { "200": { "description": "更新成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_sub.Response" } } } ] } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "404": { "description": "订阅链接不存在", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } }, "delete": { "security": [ { "BearerAuth": [] } ], "description": "根据ID删除单个订阅链接", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "订阅" ], "summary": "删除订阅链接", "parameters": [ { "type": "integer", "description": "订阅链接ID", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "删除成功", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "400": { "description": "请求参数错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "404": { "description": "订阅链接不存在", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/system/health": { "get": { "description": "检查服务健康状态,包括数据库连接状态", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "系统" ], "summary": "健康检查", "responses": { "200": { "description": "服务正常", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_system.HealthResponse" } } } ] } }, "503": { "description": "服务不可用", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/system/info": { "get": { "security": [ { "BearerAuth": [] } ], "description": "获取程序运行相关信息,包括内存使用、运行时长、网络流量、CPU信息等", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "系统" ], "summary": "系统信息", "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_system.Info" } } } ] } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/system/live": { "get": { "description": "检查服务是否存活(简单的ping检查)", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "系统" ], "summary": "存活检查", "responses": { "200": { "description": "服务存活", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/system/ready": { "get": { "description": "检查服务是否准备好接收请求", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "系统" ], "summary": "就绪检查", "responses": { "200": { "description": "服务就绪", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_system.HealthResponse" } } } ] } }, "503": { "description": "服务未就绪", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/system/version": { "get": { "security": [ { "BearerAuth": [] } ], "description": "获取程序版本信息", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "系统" ], "summary": "系统版本", "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_system.Version" } } } ] } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/update": { "get": { "security": [ { "BearerAuth": [] } ], "description": "获取程序最新版本信息", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "更新" ], "summary": "最新版本", "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "object", "additionalProperties": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_core_update.LatestInfo" } } } } ] } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } }, "/api/v1/update/:name": { "post": { "security": [ { "BearerAuth": [] } ], "description": "更新程序", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "更新" ], "summary": "更新", "responses": { "200": { "description": "获取成功", "schema": { "allOf": [ { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" }, { "type": "object", "properties": { "data": { "type": "string" } } } ] } }, "401": { "description": "未授权", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } }, "500": { "description": "服务器内部错误", "schema": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct" } } } } } }, "definitions": { "github_com_bestruirui_bestsub_internal_core_check.Desc": { "type": "object", "properties": { "desc": { "type": "string" }, "key": { "type": "string" }, "name": { "type": "string" }, "options": { "type": "string" }, "require": { "type": "boolean" }, "type": { "type": "string" }, "value": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_core_update.LatestInfo": { "type": "object", "properties": { "body": { "type": "string" }, "message": { "type": "string" }, "published_at": { "type": "string" }, "tag_name": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_models_auth.ChangePasswordRequest": { "type": "object", "required": [ "new_password", "old_password", "username" ], "properties": { "new_password": { "type": "string", "example": "new_password" }, "old_password": { "type": "string", "example": "old_password" }, "username": { "type": "string", "example": "admin" } } }, "github_com_bestruirui_bestsub_internal_models_auth.Data": { "type": "object", "properties": { "username": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_models_auth.LoginRequest": { "type": "object", "required": [ "password", "username" ], "properties": { "password": { "type": "string", "example": "admin" }, "username": { "type": "string", "example": "admin" } } }, "github_com_bestruirui_bestsub_internal_models_auth.LoginResponse": { "type": "object", "properties": { "access_expires_at": { "type": "string", "example": "2024-01-01T12:00:00Z" }, "access_token": { "type": "string", "example": "access_token_string" } } }, "github_com_bestruirui_bestsub_internal_models_auth.UpdateUserInfoRequest": { "type": "object", "required": [ "username" ], "properties": { "username": { "type": "string", "example": "admin" } } }, "github_com_bestruirui_bestsub_internal_models_check.Request": { "type": "object", "properties": { "config": {}, "enable": { "type": "boolean" }, "name": { "type": "string", "example": "测试检测任务" }, "task": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_check.Task" } } }, "github_com_bestruirui_bestsub_internal_models_check.Response": { "type": "object", "properties": { "config": {}, "enable": { "type": "boolean" }, "id": { "type": "integer" }, "name": { "type": "string" }, "result": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_check.Result" }, "status": { "type": "string" }, "task": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_check.Task" } } }, "github_com_bestruirui_bestsub_internal_models_check.Result": { "type": "object", "properties": { "duration": { "type": "integer" }, "extra": {}, "last_run": { "type": "string" }, "msg": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_models_check.Task": { "type": "object", "properties": { "cron_expr": { "type": "string", "example": "0 0 * * *" }, "log_level": { "type": "string", "example": "info" }, "log_write_file": { "type": "boolean", "example": true }, "notify": { "type": "boolean", "example": true }, "notify_channel": { "type": "integer", "example": 1 }, "sub_id": { "type": "array", "items": { "type": "integer" }, "example": [ 1 ] }, "sub_id_exclude": { "type": "boolean", "example": false }, "timeout": { "type": "integer", "example": 60 }, "type": { "type": "string", "example": "test" } } }, "github_com_bestruirui_bestsub_internal_models_node.Filter": { "type": "object", "properties": { "alive_status": { "type": "integer" }, "country": { "type": "array", "items": { "type": "string" } }, "country_exclude": { "type": "boolean" }, "delay_less_than": { "type": "integer" }, "risk_less_than": { "type": "integer" }, "speed_down_more": { "type": "integer" }, "speed_up_more": { "type": "integer" }, "sub_id": { "type": "array", "items": { "type": "integer" } }, "sub_id_exclude": { "type": "boolean" } } }, "github_com_bestruirui_bestsub_internal_models_node.SimpleInfo": { "type": "object", "properties": { "count": { "type": "integer" }, "delay": { "type": "integer" }, "risk": { "type": "integer" }, "speed_down": { "type": "integer" }, "speed_up": { "type": "integer" } } }, "github_com_bestruirui_bestsub_internal_models_notify.NameAndID": { "type": "object", "properties": { "id": { "type": "integer" }, "name": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_models_notify.Request": { "type": "object", "properties": { "config": {}, "name": { "type": "string" }, "type": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_models_notify.Response": { "type": "object", "properties": { "config": {}, "id": { "type": "integer" }, "name": { "type": "string" }, "type": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_models_notify.Template": { "type": "object", "properties": { "template": { "type": "string" }, "type": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_models_setting.Setting": { "type": "object", "properties": { "key": { "type": "string" }, "value": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_models_share.GenConfig": { "type": "object", "properties": { "filter": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_node.Filter" }, "proxy": { "type": "boolean" }, "rename": { "type": "string" }, "sub_converter": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_share.SubConverterConfig" } } }, "github_com_bestruirui_bestsub_internal_models_share.Request": { "type": "object", "properties": { "enable": { "type": "boolean" }, "expires": { "type": "integer" }, "gen": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_share.GenConfig" }, "max_access_count": { "type": "integer" }, "name": { "type": "string" }, "token": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_models_share.Response": { "type": "object", "properties": { "access_count": { "type": "integer" }, "enable": { "type": "boolean" }, "expires": { "type": "integer" }, "gen": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_share.GenConfig" }, "id": { "type": "integer" }, "max_access_count": { "type": "integer" }, "name": { "type": "string" }, "token": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_models_share.SubConverterConfig": { "type": "object", "properties": { "config": { "type": "string" }, "target": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_models_storage.Request": { "type": "object", "properties": { "config": {}, "name": { "type": "string", "example": "webdav" }, "type": { "type": "string", "example": "webdav" } } }, "github_com_bestruirui_bestsub_internal_models_storage.Response": { "type": "object", "properties": { "config": {}, "id": { "type": "integer" }, "name": { "type": "string" }, "type": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_models_sub.Config": { "type": "object", "properties": { "protocol_filter": { "type": "array", "items": { "type": "string" } }, "protocol_filter_enable": { "type": "boolean" }, "protocol_filter_mode": { "type": "boolean" }, "proxy": { "type": "boolean" }, "timeout": { "type": "integer" }, "url": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_models_sub.Request": { "type": "object", "properties": { "config": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_sub.Config" }, "cron_expr": { "type": "string", "example": "0 0 * * *" }, "enable": { "type": "boolean" }, "name": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_models_sub.Response": { "type": "object", "properties": { "config": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_sub.Config" }, "created_at": { "type": "string" }, "cron_expr": { "type": "string" }, "enable": { "type": "boolean" }, "id": { "type": "integer" }, "info": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_node.SimpleInfo" }, "name": { "type": "string" }, "result": { "$ref": "#/definitions/github_com_bestruirui_bestsub_internal_models_sub.Result" }, "status": { "type": "string" }, "updated_at": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_models_sub.Result": { "type": "object", "properties": { "duration": { "type": "integer" }, "fail": { "type": "integer" }, "last_run": { "type": "string" }, "msg": { "type": "string" }, "node_null_count": { "type": "integer" }, "raw_count": { "type": "integer" }, "success": { "type": "integer" } } }, "github_com_bestruirui_bestsub_internal_models_system.HealthResponse": { "type": "object", "properties": { "database": { "description": "数据库状态", "type": "string", "example": "connected" }, "status": { "description": "服务状态", "type": "string", "example": "ok" }, "timestamp": { "description": "检查时间", "type": "string", "example": "2024-01-01T12:00:00" }, "version": { "description": "版本信息", "type": "string", "example": "1.0.0" } } }, "github_com_bestruirui_bestsub_internal_models_system.Info": { "type": "object", "properties": { "cpu_percent": { "description": "CPU 占用率", "type": "number" }, "download_bytes": { "description": "下载流量 (bytes)", "type": "integer" }, "memory_used": { "description": "已使用内存 (bytes)", "type": "integer" }, "start_time": { "description": "启动时间", "type": "string" }, "upload_bytes": { "description": "上传流量 (bytes)", "type": "integer" } } }, "github_com_bestruirui_bestsub_internal_models_system.Version": { "type": "object", "properties": { "author": { "type": "string" }, "build_time": { "type": "string" }, "commit": { "type": "string" }, "repo": { "type": "string" }, "subconverter_version": { "type": "string" }, "version": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_modules_notify.Desc": { "type": "object", "properties": { "desc": { "type": "string" }, "key": { "type": "string" }, "name": { "type": "string" }, "options": { "type": "string" }, "require": { "type": "boolean" }, "type": { "type": "string" }, "value": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_modules_storage.Desc": { "type": "object", "properties": { "desc": { "type": "string" }, "key": { "type": "string" }, "name": { "type": "string" }, "options": { "type": "string" }, "require": { "type": "boolean" }, "type": { "type": "string" }, "value": { "type": "string" } } }, "github_com_bestruirui_bestsub_internal_server_resp.ResponseStruct": { "type": "object", "properties": { "code": { "type": "integer", "example": 200 }, "data": {}, "message": { "type": "string", "example": "success" } } } }, "securityDefinitions": { "BearerAuth": { "description": "类型为 \"Bearer\",后跟空格和 JWT 令牌。", "type": "apiKey", "name": "Authorization", "in": "header" } } } ================================================ FILE: docs/database/BESTSUB.json ================================================ { "tables": [ { "name": "auth", "comment": "", "color": "#175e7a", "fields": [ { "id": "5fSQ6mGWfRsgg-k3iArX5", "name": "id", "type": "INTEGER", "comment": "", "unique": false, "increment": false, "notNull": false, "primary": true, "default": "", "check": "" }, { "id": "p_zxx7J6E2VHvda5XLwM4", "name": "username", "type": "TEXT", "comment": "", "unique": true, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" }, { "id": "UZe2dA81_wloaZagtZqqa", "name": "password", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" } ], "indices": [], "id": "K9Bq53vxKnW3VYQjTlNhu", "x": 54, "y": 40 }, { "name": "setting", "comment": "", "color": "#175e7a", "fields": [ { "id": "C3TajAx2rVfJyKDr5wS7N", "name": "key", "type": "TEXT", "comment": "", "unique": true, "increment": false, "notNull": true, "primary": true, "default": "", "check": "" }, { "id": "VFICQh-IoYbADFMaVtyJM", "name": "value", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" } ], "indices": [], "id": "hjBAyr0lCMLDGLnEqx4mt", "x": 308, "y": 40 }, { "name": "notify_template", "comment": "", "color": "#175e7a", "fields": [ { "id": "aPVyZgMlY0rAXkhsiGN0S", "name": "type", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": true, "default": "", "check": "" }, { "id": "GedQIFxcSKnhDqDdMBmkd", "name": "template", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" } ], "indices": [], "id": "qvBLHFHDEQMcvN7Mg1Yzf", "x": 562, "y": 40 }, { "name": "notify", "comment": "", "color": "#175e7a", "fields": [ { "id": "yn7TOVZbZ2hHQsdn8YMdg", "name": "id", "type": "INTEGER", "comment": "", "unique": true, "increment": false, "notNull": true, "primary": true, "default": "", "check": "" }, { "id": "9oiTodm77XzAF_35GhPpy", "name": "name", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" }, { "id": "E8tUPCAZAWzs6Bo4R-gZ8", "name": "type", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" }, { "id": "ur8-HAWlUb3BVOYrignKn", "name": "config", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" } ], "indices": [], "id": "vpWds5GXNQffE5c_w8GTa", "x": 816, "y": 40 }, { "name": "check_task", "comment": "", "color": "#175e7a", "fields": [ { "id": "X8DIONkwFxIhFk84Vdw8H", "name": "id", "type": "INTEGER", "comment": "", "unique": false, "increment": false, "notNull": false, "primary": true, "default": "", "check": "" }, { "id": "z9fE6n_6oXg326t0wxI3z", "name": "enable", "type": "BOOLEAN", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" }, { "id": "gDeq6xOXxK-EPPP25noY3", "name": "name", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": false, "primary": false, "default": "", "check": "" }, { "id": "ZnCL2M1C-zIXaWpvar0NN", "name": "task", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" }, { "id": "TTpDOH2ASKlaqGhQjaiN3", "name": "config", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": false, "primary": false, "default": "", "check": "" }, { "id": "Jh2P1spnA7OmJRSSc62_L", "name": "result", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": false, "primary": false, "default": "", "check": "" } ], "indices": [], "id": "P26OgjsewN5mmfipR1joA", "x": 1070, "y": 40 }, { "name": "storage", "comment": "", "color": "#175e7a", "fields": [ { "id": "IqL9ngLv_3lGHVB3CzhxN", "name": "id", "type": "INTEGER", "comment": "", "unique": false, "increment": false, "notNull": false, "primary": true, "default": "", "check": "" }, { "id": "GDKYuiA0I4uDeeoBl-RyA", "name": "name", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": false, "primary": false, "default": "", "check": "" }, { "id": "BeAtbwLSpgr6HVsYy_Zly", "name": "type", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" }, { "id": "SC60MqiPB7oKm-4P9OYmB", "name": "config", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" } ], "indices": [], "id": "6zB3TFuEBkmefWBH2oIzY", "x": 1070, "y": 353 }, { "name": "sub_template", "comment": "", "color": "#175e7a", "fields": [ { "id": "aQ_KOAUAP7ff2zUH1pgIP", "name": "id", "type": "INTEGER", "comment": "", "unique": false, "increment": false, "notNull": false, "primary": true, "default": "", "check": "" }, { "id": "3iQaDIqv5rUBrzOPmSH0y", "name": "name", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": false, "primary": false, "default": "", "check": "" }, { "id": "CACizfDmfOi6i_MDE4lig", "name": "type", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" }, { "id": "aUKGH4p_TMNRqmn_jl_cY", "name": "template", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" } ], "indices": [], "id": "Nd14xBak0Dylm-vCR6Kow", "x": 816, "y": 353 }, { "name": "sub", "comment": "", "color": "#175e7a", "fields": [ { "id": "cg8ZPMZMT6kxafhRqFHv2", "name": "id", "type": "INTEGER", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": true, "default": "", "check": "" }, { "id": "j1xYw_Ql6gyzEftfgk0N7", "name": "enable", "type": "BOOLEAN", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "true", "check": "" }, { "id": "bMeAdLhPX96ajjp4Lvlt3", "name": "name", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": false, "primary": false, "default": "", "check": "" }, { "id": "1-jQ706xtwgEkdmzurPog", "name": "cron_expr", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" }, { "id": "D5bz0oxZ9bDt4_b82fMt7", "name": "config", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" }, { "id": "jLDqKaerWwupXaf4YcrZK", "name": "result", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": false, "primary": false, "default": "{}", "check": "" }, { "id": "urC5qElwekXRwqWQ-sIMc", "name": "created_at", "type": "DATETIME", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "CURRENT_TIMESTAMP", "check": "" }, { "id": "a2ntNMPEMJ1itn3br8gG4", "name": "updated_at", "type": "DATETIME", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "CURRENT_TIMESTAMP", "check": "" } ], "indices": [], "id": "1M0JLZy3hO7B02pBQuOr0", "x": 562, "y": 353 }, { "name": "share", "comment": "", "color": "#175e7a", "fields": [ { "id": "-8pCJUAo4dNElTvun64wS", "name": "id", "type": "INTEGER", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": true, "default": "", "check": "" }, { "id": "mKyLNAICw3REd4o93Pl-8", "name": "enable", "type": "BOOLEAN", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "false", "check": "" }, { "id": "zHcTEPQxG3-K1kAdgQel8", "name": "name", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" }, { "id": "9xTMb9mFYBD9iY3EaXD3o", "name": "gen", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" }, { "id": "oAZFFOq8zzeDzaTkXXeY6", "name": "token", "type": "TEXT", "comment": "", "unique": true, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" }, { "id": "O94DSBxsxULcrDKjHmS4k", "name": "access_count", "type": "INTEGER", "comment": "", "unique": false, "increment": false, "notNull": false, "primary": false, "default": "0", "check": "" }, { "id": "Y3jjjRZwP8nDwpHRM0SN8", "name": "max_access_count", "type": "INTEGER", "comment": "", "unique": false, "increment": false, "notNull": false, "primary": false, "default": "0", "check": "" }, { "id": "pwca4TOq6_tLLO6IDjGBE", "name": "expires", "type": "INTEGER", "comment": "", "unique": false, "increment": false, "notNull": false, "primary": false, "default": "0", "check": "" } ], "indices": [], "id": "C58fWZEDUJBkY_bfB0I64", "x": 308, "y": 353 }, { "name": "migration", "comment": "", "color": "#175e7a", "fields": [ { "id": "GmjplhzoTgouk4gIEq188", "name": "date", "type": "INTEGER", "comment": "", "unique": true, "increment": false, "notNull": true, "primary": true, "default": "", "check": "" }, { "id": "3GC8-bNPvJYl9TVHwYxCo", "name": "version", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" }, { "id": "xvNmYiFJ1iv5mEBAX7tHJ", "name": "description", "type": "TEXT", "comment": "", "unique": false, "increment": false, "notNull": false, "primary": false, "default": "", "check": "" }, { "id": "YZtCo5MXDSmPb8zVfuovI", "name": "applied_at", "type": "DATETIME", "comment": "", "unique": false, "increment": false, "notNull": true, "primary": false, "default": "", "check": "" } ], "indices": [], "id": "Rg-jzHbi39Wb6U2JG8omy", "x": 54, "y": 353 } ], "relationships": [], "notes": [], "subjectAreas": [], "database": "sqlite", "title": "BESTSUB" } ================================================ FILE: docs/database/README.md ================================================ # 数据库设计文档 ## 概述 本文档介绍项目的数据库设计方案及相关工具使用说明。 ## 设计图查看方法 1. 下载本目录中的设计文件:[BESTSUB.json](./BESTSUB.json) 2. 访问 [DrawDB](https://www.drawdb.app/) 在线工具 3. 将下载的JSON文件导入到DrawDB中 4. 即可查看完整的数据库设计图及表结构关系 ## 推荐工具 - **数据库设计工具**:[DrawDB](https://github.com/drawdb-io/drawdb) - 免费且功能强大的数据库设计工具 - **SQLite可视化工具**:[sqlite3-editor](https://github.com/yy0931/sqlite3-editor) - 方便直观的SQLite数据库查看和编辑工具 ## 使用说明 在开发过程中,请参照数据库设计图进行数据库操作,确保数据结构的一致性和完整性。如需修改数据库设计,请更新设计文件并同步到项目中。 ================================================ FILE: docs/rename/README.md ================================================ # BestSub 节点重命名模板指南 BestSub 节点重命名功能允许用户自定义节点名称的显示格式,通过灵活的模板语法实现个性化的节点管理体验。 --- ## 📋 可用变量 重命名模板支持以下变量: | 变量 | 说明 | 示例 | |-----------------------|-------------------|------------------| | `{{.Count}}` | 节点序号 (必填,从1开始) | 1, 2, 3 | | `{{.SpeedUp}}` | 上行速度 (平均,单位:KB/s) | 102400, 51200 | | `{{.SpeedDown}}` | 下行速度 (平均,单位:KB/s) | 102400, 51200 | | `{{.Delay}}` | 延迟 (平均,单位:毫秒) | 45, 120 | | `{{.Risk}}` | 风险等级 (数字越小越好) | 1, 2, 3 | | `{{.Country.NameEn}}` | 国家/地区代码 | JP, US, SG | | `{{.Country.NameZh}}` | 国家/地区中文名称 | 日本, 美国, 新加坡 | | `{{.Country.Emoji}}` | 国家/地区旗帜表情符号 | 🇯🇵, 🇺🇸, 🇸🇬 | | `{{.SubName}}` | 订阅名称 | 未知订阅 | | `{{.SubTags}}` | 订阅标签 | \ | | `{{.SubTagsOrigin}}` | 订阅标签(原始数组) | ["Tag1", "Tag2"] | > 注意:`.SubTagsOrigin` 类型为 `[]string`,因此不能直接在重命名模板中使用 --- ## 🚀 快速开始 ### 立即可用的推荐模板 #### 简洁美观格式 ```go {{.Country.Emoji}}{{.Country.NameZh}}-{{.Delay}}ms ``` 输出示例:`🇯🇵日本-45ms`, `🇺🇸美国-120ms` #### 游戏玩家格式 (低延迟优先) ```go {{if le .Delay 50}}🚀{{else if le .Delay 100}}⚡{{else}}🐌{{end}}{{.Country.NameZh}} ``` 输出示例:`🚀日本`, `⚡美国`, `🐌其他` #### 下载专用格式 (高速度优先) ```go {{div .SpeedDown 1024}}MB/s-{{.Country.Emoji}}{{.Country.NameZh}} ``` 输出示例:`100MB/s-🇯🇵日本`, `50MB/s-🇺🇸美国` --- ## 🎨 模板库 ### 基础模板 #### 简单格式 ```go 节点{{.Count}} ``` 输出示例:`节点1`, `节点2`, `节点3` #### 带国家信息 ```go {{.Country.NameZh}}-节点{{.Count}} ``` 输出示例:`日本-节点1`, `美国-节点2` #### 带国旗格式 ```go {{.Country.Emoji}}{{.Country.NameZh}}-{{.Count}} ``` 输出示例:`🇯🇵日本-1`, `🇺🇸美国-2` #### 基础性能格式 ```go {{.Country.Emoji}}{{.Country.NameZh}}-{{.Delay}}ms ``` 输出示例:`🇯🇵日本-45ms`, `🇺🇸美国-120ms` ### 推荐模板 #### 格式1:简洁信息 ```go {{.Country.Emoji}}{{.Country.NameZh}}-{{.Delay}}ms ``` 输出示例:`🇯🇵日本-45ms`, `🇺🇸美国-120ms` #### 格式2:速度优先 ```go {{div .SpeedDown 1024}}MB/s-{{.Country.Emoji}}{{.Country.NameZh}} ``` 输出示例:`100MB/s-🇯🇵日本`, `50MB/s-🇺🇸美国` #### 格式3:完整信息 ```go {{printf "%03d" .Count}}-{{.Country.Emoji}}{{.Country.NameZh}}-{{.Delay}}ms-{{div .SpeedDown 1024}}MB/s ``` 输出示例:`001-🇯🇵日本-45ms-100MB/s` #### 格式4:质量评级 ```go {{.Country.Emoji}}{{.Country.NameZh}}-{{if le .Delay 50}}极速{{else if le .Delay 100}}快速{{else}}普通{{end}} ``` 输出示例:`🇯🇵日本-极速`, `🇺🇸美国-快速` ### 高级模板 #### 数字格式化 ```go {{printf "%03d" .Count}}-{{.Country.NameZh}} ``` 输出示例:`001-日本`, `002-美国`, `010-新加坡` #### 条件判断 ```go {{.Country.NameZh}}{{if eq .Risk 1}}✅{{else if eq .Risk 2}}⚠️{{else}}❌{{end}} ``` 输出示例:`日本✅`, `美国⚠️`, `其他❌` #### 速度质量标识 ```go {{if ge .SpeedDown 51200}}🚀{{else if ge .SpeedDown 10240}}⚡{{else}}🐌{{end}}{{.Country.NameZh}} ``` 输出示例:`🚀日本`, `⚡美国`, `🐌其他` #### 延迟分级 ```go {{if le .Delay 50}}极速{{else if le .Delay 100}}快速{{else if le .Delay 200}}普通{{else}}较慢{{end}}-{{.Country.NameZh}} ``` 输出示例:`极速-日本`, `快速-美国`, `普通-新加坡` ### 场景专用模板 #### 游戏玩家专用 ```go 🎮{{printf "%03d" .Count}}-{{.Country.Emoji}}{{.Country.NameZh}}-{{if le .Delay 50}}4星{{else if le .Delay 100}}3星{{else}}2星{{end}} ``` #### 工作办公专用 ```go 💼{{.Country.NameZh}}-{{if le .Delay 100}}稳定{{else}}一般{{end}}-{{div .SpeedDown 1024}}MB ``` #### 视频流媒体专用 ```go 📺{{.Country.Emoji}}{{.Country.NameZh}}-{{if ge .SpeedDown 25600}}4K{{else if ge .SpeedDown 10240}}1080P{{else}}720P{{end}} ``` #### 开发者专用 ```go {{.Country.NameEn}}{{printf "%03d" .Count}}|{{.Delay}}ms|{{div .SpeedDown 1024}}MB|Risk{{.Risk}} ``` #### 专业监控风格 ```go [{{.Country.NameEn}}] Ping:{{.Delay}}ms|Down:{{div .SpeedDown 1024}}MB/s|Risk:{{.Risk}} ``` 输出示例:`[JP] Ping:45ms|Down:100MB/s|Risk:1` #### 质量评分系统 ```go {{.Country.Emoji}}{{.Country.NameZh}}-{{if ge .SpeedDown 51200}}{{if le .Delay 50}}S{{else if le .Delay 100}}A{{else}}B{{end}}{{else if ge .SpeedDown 10240}}{{if le .Delay 50}}A{{else if le .Delay 100}}B{{else}}C{{end}}{{else}}C{{end}} ``` 输出示例:`🇯🇵日本-S`, `🇺🇸美国-B`, `🇸🇬新加坡-C` --- ## 📚 函数参考 ### 数学运算 - `add x y` - 加法:`{{add .Count 1}}` - `sub x y` - 减法:`{{sub 100 .Delay}}` - `div x y` - 除法:`{{div .SpeedDown 1024}}` - `mod x y` - 取余:`{{mod .Count 2}}` ### 比较运算 - `eq x y` - 等于:`{{if eq .Risk 1}}安全{{end}}` - `ne x y` - 不等于:`{{if ne .Delay 0}}正常{{end}}` - `lt x y` - 小于:`{{if lt .Delay 50}}极速{{end}}` - `le x y` - 小于等于:`{{if le .Delay 100}}快速{{end}}` - `gt x y` - 大于:`{{if gt .SpeedDown 51200}}高速{{end}}` - `ge x y` - 大于等于:`{{if ge .SpeedDown 10240}}可用{{end}}` ### 逻辑运算 - `and x y` - 逻辑与:`{{if and (ge .SpeedDown 51200) (le .Delay 50)}}极品{{end}}` - `or x y` - 逻辑或:`{{if or (le .Delay 50) (ge .SpeedDown 51200)}}推荐{{end}}` - `not x` - 逻辑非:`{{if not (eq .Risk 3)}}安全{{end}}` ### 字符串处理 - `printf format args...` - 格式化:`{{printf "%03d" .Count}}` - `slice s start end` - 切片:`{{slice .Country.NameEn 0 2}}` ### 数组处理 + `for index item` - 循环:`<{{range $i, $v := .SubTags}}{{if $i}}|{{end}}{{$v}}{{end}}>` --- ## 💡 使用技巧 ### 单位转换 - KB/s 转 MB/s:`{{div .SpeedDown 1024}}MB/s` - ms 转 s:`{{div .Delay 1000}}.{{mod .Delay 1000}}s` - 智能速度单位:`{{if ge .SpeedDown 1024}}{{div .SpeedDown 1024}}MB/s{{else}}{{.SpeedDown}}KB/s{{end}}` ### 条件组合 ```go {{if and (ge .SpeedDown 51200) (le .Delay 50)}}🚀{{else if and (ge .SpeedDown 10240) (le .Delay 100)}}⚡{{else}}🐌{{end}}{{.Country.NameZh}} ``` ### 性能分级 ```go {{.Country.NameZh}}-{{if le .Delay 30}}S+{{else if le .Delay 50}}S{{else if le .Delay 100}}A{{else if le .Delay 200}}B{{else}}C{{end}} ``` ### 风险标识 ```go {{.Country.Emoji}}{{.Country.NameZh}}{{if eq .Risk 1}}🟢{{else if eq .Risk 2}}🟡{{else if eq .Risk 3}}🟠{{else}}🔴{{end}} ``` --- ## 📖 快速参考 ### 常用阈值 - **延迟等级**: - 极速:≤ 50ms - 快速:≤ 100ms - 普通:≤ 200ms - 较慢:> 200ms - **速度等级** (KB/s): - 极速:≥ 51200 (50MB/s) - 快速:≥ 10240 (10MB/s) - 普通:≥ 2048 (2MB/s) - 较慢:< 2048 - **视频质量要求**: - 4K:≥ 25600 KB/s - 1080P:≥ 10240 KB/s - 720P:≥ 5120 KB/s ### 颜色建议 - 🟢 安全:风险等级 1 - 🟡 注意:风险等级 2 - 🟠 警告:风险等级 3 - 🔴 危险:风险等级 ≥ 4 --- ## ⚠️ 注意事项 - **必填项**:每个模板都必须包含 `{{.Count}}` 变量 - **单位说明**:速度变量单位为 KB/s,延迟变量单位为毫秒 - **语法规范**:使用 Go 语言的 `text/template` 语法 - **大小写敏感**:所有变量名区分大小写,请确保使用正确的变量名 - **字符转义**:模板中的引号需要转义,如 `\"` --- ## ❓ 常见问题 **Q: 模板中必须包含哪些变量?** A: `{{.Count}}` 是必填项,其他变量可根据需要选择使用 **Q: 如何将速度单位从 KB/s 转换为 MB/s?** A: 使用 `{{div .SpeedDown 1024}}MB/s` 进行单位转换 **Q: 如何根据延迟给节点进行分级显示?** A: 使用条件判断语句,如 `{{if le .Delay 50}}极速{{end}}` **Q: 模板支持哪些数学运算?** A: 支持加减乘除、取余等基本数学运算 **Q: 模板语法错误应该如何排查?** A: 请检查变量名大小写、括号匹配和条件语句完整性 **Q: 为什么我的模板没有生效?** A: 请检查是否包含了必填的 `{{.Count}}` 变量 **Q: 如何将节点序号显示为三位数格式?** A: 使用 `{{printf "%03d" .Count}}` 可以格式化为 001, 002... **Q: 如何根据不同的速度显示对应的图标?** A: 使用条件判断:`{{if ge .SpeedDown 51200}}🚀{{else}}⚡{{end}}` ================================================ FILE: go.mod ================================================ module github.com/bestruirui/bestsub go 1.24.2 require ( github.com/cespare/xxhash/v2 v2.3.0 github.com/enfein/mieru/v3 v3.30.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/websocket v1.5.3 github.com/metacubex/mihomo v1.19.23 github.com/panjf2000/ants/v2 v2.11.4 github.com/robfig/cron/v3 v3.0.1 github.com/shirou/gopsutil/v4 v4.26.1 go.uber.org/zap v1.27.1 golang.org/x/crypto v0.47.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.44.3 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/RyuaNerin/go-krypto v1.3.0 // indirect github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 // indirect github.com/ajg/form v1.5.1 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/coreos/go-iptables v0.8.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dunglas/httpsfv v1.0.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.9.1 // indirect github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 // indirect github.com/ericlagergren/polyval v0.0.0-20230805202542-18692a1b76f9 // indirect github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1 // indirect github.com/ericlagergren/subtle v0.0.0-20220507045147-890d697da010 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gaukas/godicttls v0.0.4 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/gofrs/uuid/v5 v5.4.0 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d // indirect github.com/google/uuid v1.6.0 // indirect github.com/insomniacslk/dhcp v0.0.0-20251020182700-175e84fbb167 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/reedsolomon v1.13.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mdlayher/netlink v1.8.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/metacubex/amneziawg-go v0.0.0-20251104174305-5a0e9f7e361d // indirect github.com/metacubex/ascon v0.1.0 // indirect github.com/metacubex/bart v0.26.0 // indirect github.com/metacubex/bbolt v0.0.0-20250725135710-010dbbbb7a5b // indirect github.com/metacubex/blake3 v0.1.0 // indirect github.com/metacubex/chacha v0.1.5 // indirect github.com/metacubex/chi v0.1.0 // indirect github.com/metacubex/connect-ip-go v0.0.0-20260128031117-1cad62060727 // indirect github.com/metacubex/cpu v0.1.1 // indirect github.com/metacubex/edwards25519 v1.2.0 // indirect github.com/metacubex/fswatch v0.1.1 // indirect github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 // indirect github.com/metacubex/gvisor v0.0.0-20251227095601-261ec1326fe8 // indirect github.com/metacubex/hkdf v0.1.0 // indirect github.com/metacubex/hpke v0.1.0 // indirect github.com/metacubex/http v0.1.1 // indirect github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 // indirect github.com/metacubex/mhurl v0.1.0 // indirect github.com/metacubex/mlkem v0.1.0 // indirect github.com/metacubex/nftables v0.0.0-20250503052935-30a69ab87793 // indirect github.com/metacubex/qpack v0.6.0 // indirect github.com/metacubex/quic-go v0.59.1-0.20260213014310-4df8f0de5b56 // indirect github.com/metacubex/randv2 v0.2.0 // indirect github.com/metacubex/restls-client-go v0.1.7 // indirect github.com/metacubex/sing v0.5.7 // indirect github.com/metacubex/sing-mux v0.3.5 // indirect github.com/metacubex/sing-quic v0.0.0-20260112044712-65d17608159e // indirect github.com/metacubex/sing-shadowsocks v0.2.12 // indirect github.com/metacubex/sing-shadowsocks2 v0.2.7 // indirect github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2 // indirect github.com/metacubex/sing-tun v0.4.17 // indirect github.com/metacubex/sing-vmess v0.2.5 // indirect github.com/metacubex/sing-wireguard v0.0.0-20250503063753-2dc62acc626f // indirect github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 // indirect github.com/metacubex/tfo-go v0.0.0-20251204144243-738de9e3cd15 // indirect github.com/metacubex/tls v0.1.5 // indirect github.com/metacubex/utls v1.8.4 // indirect github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f // indirect github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 // indirect github.com/miekg/dns v1.1.72 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mroth/weightedrand/v2 v2.1.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7 // indirect github.com/openacid/low v0.1.21 // indirect github.com/oschwald/maxminddb-golang v1.12.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect github.com/samber/lo v1.53.0 // indirect github.com/sina-ghaderi/poly1305 v0.0.0-20220724002748-c5926b03988b // indirect github.com/sina-ghaderi/rabaead v0.0.0-20220730151906-ab6e06b96e8c // indirect github.com/sina-ghaderi/rabbitio v0.0.0-20220730151941-9ce26f4f872e // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/vishvananda/netns v0.0.4 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7 // indirect gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/mock v0.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/arch v0.23.0 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.41.0 // indirect google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.67.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) ================================================ FILE: go.sum ================================================ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/RyuaNerin/go-krypto v1.3.0 h1:smavTzSMAx8iuVlGb4pEwl9MD2qicqMzuXR2QWp2/Pg= github.com/RyuaNerin/go-krypto v1.3.0/go.mod h1:9R9TU936laAIqAmjcHo/LsaXYOZlymudOAxjaBf62UM= github.com/RyuaNerin/testingutil v0.1.0 h1:IYT6JL57RV3U2ml3dLHZsVtPOP6yNK7WUVdzzlpNrss= github.com/RyuaNerin/testingutil v0.1.0/go.mod h1:yTqj6Ta/ycHMPJHRyO12Mz3VrvTloWOsy23WOZH19AA= github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 h1:cDVUiFo+npB0ZASqnw4q90ylaVAbnYyx0JYqK4YcGok= github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344/go.mod h1:9pIqrY6SXNL8vjRQE5Hd/OL5GyK/9MrGUWs87z/eFfk= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc= github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0= github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/enfein/mieru/v3 v3.27.0 h1:+E/1TF7OfimS2h582atEXQxPtJMyvqUTFBJUgzn1rxg= github.com/enfein/mieru/v3 v3.27.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= github.com/enfein/mieru/v3 v3.30.0 h1:g7v0TuK7y0ZMn6TOdjOs8WEUQk8bvs6WYPBJ16SKdBU= github.com/enfein/mieru/v3 v3.30.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo= github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I= github.com/ericlagergren/polyval v0.0.0-20230805202542-18692a1b76f9 h1:NUmyvuwVoDsIFzOGFKW4zpCtQTbX2T4JpSn1jal64gM= github.com/ericlagergren/polyval v0.0.0-20230805202542-18692a1b76f9/go.mod h1:aXxf//HFNaacVV7/YZ8qevpNZAEoxSCpoBjscNhjrCI= github.com/ericlagergren/saferand v0.0.0-20220206064634-960a4dd2bc5c h1:RUzBDdZ+e/HEe2Nh8lYsduiPAZygUfVXJn0Ncj5sHMg= github.com/ericlagergren/saferand v0.0.0-20220206064634-960a4dd2bc5c/go.mod h1:ETASDWf/FmEb6Ysrtd1QhjNedUU/ZQxBCRLh60bQ/UI= github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1 h1:tlDMEdcPRQKBEz5nGDMvswiajqh7k8ogWRlhRwKy5mY= github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1/go.mod h1:4RfsapbGx2j/vU5xC/5/9qB3kn9Awp1YDiEnN43QrJ4= github.com/ericlagergren/subtle v0.0.0-20220507045147-890d697da010 h1:fuGucgPk5dN6wzfnxl3D0D3rVLw4v2SbBT9jb4VnxzA= github.com/ericlagergren/subtle v0.0.0-20220507045147-890d697da010/go.mod h1:JtBcj7sBuTTRupn7c2bFspMDIObMJsVK8TeUvpShPok= github.com/ericlagergren/testutil v0.0.0-20220814024112-d21c9429edc2 h1:j9adob+s2qXdvdeJywrVifDfHAIq0XwoaK/0q4D1BGw= github.com/ericlagergren/testutil v0.0.0-20220814024112-d21c9429edc2/go.mod h1:E4aJHbNMb6zjyVd1Mrpf3FIJ6kAtnVUq2yl0T6DHZ/I= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk= github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0= github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/tink/go v1.6.1 h1:t7JHqO8Ath2w2ig5vjwQYJzhGEZymedQc90lQXUBa4I= github.com/google/tink/go v1.6.1/go.mod h1:IGW53kTgag+st5yPhKKwJ6u2l+SSp5/v9XF7spovjlY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/insomniacslk/dhcp v0.0.0-20251020182700-175e84fbb167 h1:MEufgJohwIjFi2n3eJv4c/8UdRLQVUwPwSWQPoER+eU= github.com/insomniacslk/dhcp v0.0.0-20251020182700-175e84fbb167/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/reedsolomon v1.13.0 h1:E0Cmgf2kMuhZTj6eefnvpKC4/Q4jhCi9YIjcZjK4arc= github.com/klauspost/reedsolomon v1.13.0/go.mod h1:ggJT9lc71Vu+cSOPBlxGvBN6TfAS77qB4fp8vJ05NSA= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM= github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHid9HK3594= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/metacubex/amneziawg-go v0.0.0-20251104174305-5a0e9f7e361d h1:vAJ0ZT4aO803F1uw2roIA9yH7Sxzox34tVVyye1bz6c= github.com/metacubex/amneziawg-go v0.0.0-20251104174305-5a0e9f7e361d/go.mod h1:MsM/5czONyXMJ3PRr5DbQ4O/BxzAnJWOIcJdLzW6qHY= github.com/metacubex/ascon v0.1.0 h1:6ZWxmXYszT1XXtwkf6nxfFhc/OTtQ9R3Vyj1jN32lGM= github.com/metacubex/ascon v0.1.0/go.mod h1:eV5oim4cVPPdEL8/EYaTZ0iIKARH9pnhAK/fcT5Kacc= github.com/metacubex/bart v0.26.0 h1:d/bBTvVatfVWGfQbiDpYKI1bXUJgjaabB2KpK1Tnk6w= github.com/metacubex/bart v0.26.0/go.mod h1:DCcyfP4MC+Zy7sLK7XeGuMw+P5K9mIRsYOBgiE8icsI= github.com/metacubex/bbolt v0.0.0-20250725135710-010dbbbb7a5b h1:j7dadXD8I2KTmMt8jg1JcaP1ANL3JEObJPdANKcSYPY= github.com/metacubex/bbolt v0.0.0-20250725135710-010dbbbb7a5b/go.mod h1:+WmP0VJZDkDszvpa83HzfUp6QzARl/IKkMorH4+nODw= github.com/metacubex/blake3 v0.1.0 h1:KGnjh/56REO7U+cgZA8dnBhxdP7jByrG7hTP+bu6cqY= github.com/metacubex/blake3 v0.1.0/go.mod h1:CCkLdzFrqf7xmxCdhQFvJsRRV2mwOLDoSPg6vUTB9Uk= github.com/metacubex/chacha v0.1.5 h1:fKWMb/5c7ZrY8Uoqi79PPFxl+qwR7X/q0OrsAubyX2M= github.com/metacubex/chacha v0.1.5/go.mod h1:Djn9bPZxLTXbJFSeyo0/qzEzQI+gUSSzttuzZM75GH8= github.com/metacubex/chi v0.1.0 h1:rjNDyDj50nRpicG43CNkIw4ssiCbmDL8d7wJXKlUCsg= github.com/metacubex/chi v0.1.0/go.mod h1:zM5u5oMQt8b2DjvDHvzadKrP6B2ztmasL1YHRMbVV+g= github.com/metacubex/connect-ip-go v0.0.0-20260128031117-1cad62060727 h1:qbZQ0sO0bDBKPvTd/qNQK6513300WJ5GRsHnw3PO4Ho= github.com/metacubex/connect-ip-go v0.0.0-20260128031117-1cad62060727/go.mod h1:xYC8Ik7/rN6no+vTRuWMEziGwm3brA0wNM/zZP9qhOQ= github.com/metacubex/cpu v0.1.0 h1:8PeTdV9j6UKbN1K5Jvtbi/Jock7dknvzyYuLb8Conmk= github.com/metacubex/cpu v0.1.0/go.mod h1:09VEt4dSRLR+bOA8l4w4NDuzGZ8n5dkMv7e8axgEeTU= github.com/metacubex/cpu v0.1.1 h1:rRV5HGmeuGzjiKI3hYbL0dCd0qGwM7VUtk4ICXD06mI= github.com/metacubex/cpu v0.1.1/go.mod h1:09VEt4dSRLR+bOA8l4w4NDuzGZ8n5dkMv7e8axgEeTU= github.com/metacubex/edwards25519 v1.2.0 h1:pIQZLBsjQgg3Nl/c86YYFEUAbL5qQRnPq4LrgIw0KK4= github.com/metacubex/edwards25519 v1.2.0/go.mod h1:NCQF3J/Ki7382FJuokwsywEIIEI/gro/3smyXgQJsx0= github.com/metacubex/fswatch v0.1.1 h1:jqU7C/v+g0qc2RUFgmAOPoVvfl2BXXUXEumn6oQuxhU= github.com/metacubex/fswatch v0.1.1/go.mod h1:czrTT7Zlbz7vWft8RQu9Qqh+JoX+Nnb+UabuyN1YsgI= github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 h1:cjd4biTvOzK9ubNCCkQ+ldc4YSH/rILn53l/xGBFHHI= github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759/go.mod h1:UHOv2xu+RIgLwpXca7TLrXleEd4oR3sPatW6IF8wU88= github.com/metacubex/gvisor v0.0.0-20251227095601-261ec1326fe8 h1:hUL81H0Ic/XIDkvtn9M1pmfDdfid7JzYQToY4Ps1TvQ= github.com/metacubex/gvisor v0.0.0-20251227095601-261ec1326fe8/go.mod h1:8LpS0IJW1VmWzUm3ylb0e2SK5QDm5lO/2qwWLZgRpBU= github.com/metacubex/hkdf v0.1.0 h1:fPA6VzXK8cU1foc/TOmGCDmSa7pZbxlnqhl3RNsthaA= github.com/metacubex/hkdf v0.1.0/go.mod h1:3seEfds3smgTAXqUGn+tgEJH3uXdsUjOiduG/2EtvZ4= github.com/metacubex/hpke v0.1.0 h1:gu2jUNhraehWi0P/z5HX2md3d7L1FhPQE6/Q0E9r9xQ= github.com/metacubex/hpke v0.1.0/go.mod h1:vfDm6gfgrwlXUxKDkWbcE44hXtmc1uxLDm2BcR11b3U= github.com/metacubex/http v0.1.0 h1:Jcy0I9zKjYijSUaksZU34XEe2xNdoFkgUTB7z7K5q0o= github.com/metacubex/http v0.1.0/go.mod h1:Nxx0zZAo2AhRfanyL+fmmK6ACMtVsfpwIl1aFAik2Eg= github.com/metacubex/http v0.1.1 h1:zVea0zOoaZvxe51EvJMRWGNQv6MvWqJhkZSuoAjOjVw= github.com/metacubex/http v0.1.1/go.mod h1:Nxx0zZAo2AhRfanyL+fmmK6ACMtVsfpwIl1aFAik2Eg= github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 h1:hJwCVlE3ojViC35MGHB+FBr8TuIf3BUFn2EQ1VIamsI= github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604/go.mod h1:lpmN3m269b3V5jFCWtffqBLS4U3QQoIid9ugtO+OhVc= github.com/metacubex/mhurl v0.1.0 h1:ZdW4Zxe3j3uJ89gNytOazHu6kbHn5owutN/VfXOI8GE= github.com/metacubex/mhurl v0.1.0/go.mod h1:2qpQImCbXoUs6GwJrjuEXKelPyoimsIXr07eNKZdS00= github.com/metacubex/mihomo v1.19.20 h1:S2sPZILo5VjsUVka/KJR0F9lyxGkeHKSkLYLcjx5PbE= github.com/metacubex/mihomo v1.19.20/go.mod h1:XC0nYFIkDkEFzggZLXLbcnGmjlMm2zivIDZrlmD/zd0= github.com/metacubex/mihomo v1.19.23 h1:yHxEyIwu1XstpUFw7SqHBCWO//KrQYkhG18XB86y0Ns= github.com/metacubex/mihomo v1.19.23/go.mod h1:xlgWFVL2IfTT8cOkJ8+oXeoc5xNQko+YGvw6MOS8au0= github.com/metacubex/mlkem v0.1.0 h1:wFClitonSFcmipzzQvax75beLQU+D7JuC+VK1RzSL8I= github.com/metacubex/mlkem v0.1.0/go.mod h1:amhaXZVeYNShuy9BILcR7P0gbeo/QLZsnqCdL8U2PDQ= github.com/metacubex/nftables v0.0.0-20250503052935-30a69ab87793 h1:1Qpuy+sU3DmyX9HwI+CrBT/oLNJngvBorR2RbajJcqo= github.com/metacubex/nftables v0.0.0-20250503052935-30a69ab87793/go.mod h1:RjRNb4G52yAgfR+Oe/kp9G4PJJ97Fnj89eY1BFO3YyA= github.com/metacubex/qpack v0.6.0 h1:YqClGIMOpiRYLjV1qOs483Od08MdPgRnHjt90FuaAKw= github.com/metacubex/qpack v0.6.0/go.mod h1:lKGSi7Xk94IMvHGOmxS9eIei3bvIqpOAImEBsaOwTkA= github.com/metacubex/quic-go v0.59.1-0.20260128071132-0f3233b973af h1:do5o1rzn64NEN5oGswo7VruDkbz2055fhVT3rXehA8E= github.com/metacubex/quic-go v0.59.1-0.20260128071132-0f3233b973af/go.mod h1:oNzMrmylS897M3zSMuapIdwSwfq6F2qW01Z3NhVRJhk= github.com/metacubex/quic-go v0.59.1-0.20260213014310-4df8f0de5b56 h1:7yfF31COW2hiCovb5+3uSxRl3UKWOXjpS0j4N5U0qZ8= github.com/metacubex/quic-go v0.59.1-0.20260213014310-4df8f0de5b56/go.mod h1:oNzMrmylS897M3zSMuapIdwSwfq6F2qW01Z3NhVRJhk= github.com/metacubex/randv2 v0.2.0 h1:uP38uBvV2SxYfLj53kuvAjbND4RUDfFJjwr4UigMiLs= github.com/metacubex/randv2 v0.2.0/go.mod h1:kFi2SzrQ5WuneuoLLCMkABtiBu6VRrMrWFqSPyj2cxY= github.com/metacubex/restls-client-go v0.1.7 h1:eCwiXCTQb5WJu9IlgYvDBA1OgrINv58dEe7hcN5H15k= github.com/metacubex/restls-client-go v0.1.7/go.mod h1:BN/U52vPw7j8VTSh2vleD/MnmVKCov84mS5VcjVHH4g= github.com/metacubex/sing v0.5.7 h1:8OC+fhKFSv/l9ehEhJRaZZAOuthfZo68SteBVLe8QqM= github.com/metacubex/sing v0.5.7/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w= github.com/metacubex/sing-mux v0.3.5 h1:UqVN+o62SR8kJaC9/3VfOc5UiVqgVY/ef9WwfGYYkk0= github.com/metacubex/sing-mux v0.3.5/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc= github.com/metacubex/sing-quic v0.0.0-20260112044712-65d17608159e h1:MLxp42z9Jd6LtY2suyawnl24oNzIsFxWc15bNeDIGxA= github.com/metacubex/sing-quic v0.0.0-20260112044712-65d17608159e/go.mod h1:+lgKTd52xAarGtqugALISShyw4KxnoEpYe2u0zJh26w= github.com/metacubex/sing-shadowsocks v0.2.12 h1:Wqzo8bYXrK5aWqxu/TjlTnYZzAKtKsaFQBdr6IHFaBE= github.com/metacubex/sing-shadowsocks v0.2.12/go.mod h1:2e5EIaw0rxKrm1YTRmiMnDulwbGxH9hAFlrwQLQMQkU= github.com/metacubex/sing-shadowsocks2 v0.2.7 h1:hSuuc0YpsfiqYqt1o+fP4m34BQz4e6wVj3PPBVhor3A= github.com/metacubex/sing-shadowsocks2 v0.2.7/go.mod h1:vOEbfKC60txi0ca+yUlqEwOGc3Obl6cnSgx9Gf45KjE= github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2 h1:gXU+MYPm7Wme3/OAY2FFzVq9d9GxPHOqu5AQfg/ddhI= github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2/go.mod h1:mbfboaXauKJNIHJYxQRa+NJs4JU9NZfkA+I33dS2+9E= github.com/metacubex/sing-tun v0.4.17 h1:ehzvPLyxG1vmjaKVeB0aEK1eqhR3reEzdbqQfM3+5XA= github.com/metacubex/sing-tun v0.4.17/go.mod h1:L/TjQY5JEGy8nvsuYmy/XgMFMCPiF0+AWSFCYfS6r9w= github.com/metacubex/sing-vmess v0.2.5 h1:m9Zt5I27lB9fmLMZfism9sH2LcnAfShZfwSkf6/KJoE= github.com/metacubex/sing-vmess v0.2.5/go.mod h1:AwtlzUgf8COe9tRYAKqWZ+leDH7p5U98a0ZUpYehl8Q= github.com/metacubex/sing-wireguard v0.0.0-20250503063753-2dc62acc626f h1:Sr/DYKYofKHKc4GF3qkRGNuj6XA6c0eqPgEDN+VAsYU= github.com/metacubex/sing-wireguard v0.0.0-20250503063753-2dc62acc626f/go.mod h1:jpAkVLPnCpGSfNyVmj6Cq4YbuZsFepm/Dc+9BAOcR80= github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 h1:DK2l6m2Fc85H2BhiAPgbJygiWhesPlfGmF+9Vw6ARdk= github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141/go.mod h1:/yI4OiGOSn0SURhZdJF3CbtPg3nwK700bG8TZLMBvAg= github.com/metacubex/tfo-go v0.0.0-20251204144243-738de9e3cd15 h1:XKUOMjFYUGOU5sLwbSbGgGI0oOcTrrs1gLCoUB0Kg4M= github.com/metacubex/tfo-go v0.0.0-20251204144243-738de9e3cd15/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw= github.com/metacubex/tls v0.1.4 h1:Gm5GrkyMUh52gYOMIAQ1kHIym4v4M3Qb87Wsmd8Kpdc= github.com/metacubex/tls v0.1.4/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM= github.com/metacubex/tls v0.1.5 h1:ECcB83dj+zadnhlKcLnUUf1Sq6+vU0f/zoyU0+9oPTc= github.com/metacubex/tls v0.1.5/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM= github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f h1:FGBPRb1zUabhPhDrlKEjQ9lgIwQ6cHL4x8M9lrERhbk= github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f/go.mod h1:oPGcV994OGJedmmxrcK9+ni7jUEMGhR+uVQAdaduIP4= github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 h1:lhlqpYHopuTLx9xQt22kSA9HtnyTDmk5XjjQVCGHe2E= github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49/go.mod h1:MBeEa9IVBphH7vc3LNtW6ZujVXFizotPo3OEiHQ+TNU= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mroth/weightedrand/v2 v2.1.0 h1:o1ascnB1CIVzsqlfArQQjeMy1U0NcIbBO5rfd5E/OeU= github.com/mroth/weightedrand/v2 v2.1.0/go.mod h1:f2faGsfOGOwc1p94wzHKKZyTpcJUW7OJ/9U4yfiNAOU= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7 h1:1102pQc2SEPp5+xrS26wEaeb26sZy6k9/ZXlZN+eXE4= github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7/go.mod h1:UqoUn6cHESlliMhOnKLWr+CBH+e3bazUPvFj1XZwAjs= github.com/openacid/errors v0.8.1/go.mod h1:GUQEJJOJE3W9skHm8E8Y4phdl2LLEN8iD7c5gcGgdx0= github.com/openacid/low v0.1.21 h1:Tr2GNu4N/+rGRYdOsEHOE89cxUIaDViZbVmKz29uKGo= github.com/openacid/low v0.1.21/go.mod h1:q+MsKI6Pz2xsCkzV4BLj7NR5M4EX0sGz5AqotpZDVh0= github.com/openacid/must v0.1.3/go.mod h1:luPiXCuJlEo3UUFQngVQokV0MPGryeYvtCbQPs3U1+I= github.com/openacid/testkeys v0.1.6/go.mod h1:MfA7cACzBpbiwekivj8StqX0WIRmqlMsci1c37CA3Do= github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs= github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY= github.com/panjf2000/ants/v2 v2.11.4 h1:UJQbtN1jIcI5CYNocTj0fuAUYvsLjPoYi0YuhqV/Y48= github.com/panjf2000/ants/v2 v2.11.4/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo= github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc= github.com/sina-ghaderi/poly1305 v0.0.0-20220724002748-c5926b03988b h1:rXHg9GrUEtWZhEkrykicdND3VPjlVbYiLdX9J7gimS8= github.com/sina-ghaderi/poly1305 v0.0.0-20220724002748-c5926b03988b/go.mod h1:X7qrxNQViEaAN9LNZOPl9PfvQtp3V3c7LTo0dvGi0fM= github.com/sina-ghaderi/rabaead v0.0.0-20220730151906-ab6e06b96e8c h1:DjKMC30y6yjG3IxDaeAj3PCoRr+IsO+bzyT+Se2m2Hk= github.com/sina-ghaderi/rabaead v0.0.0-20220730151906-ab6e06b96e8c/go.mod h1:NV/a66PhhWYVmUMaotlXJ8fIEFB98u+c8l/CQIEFLrU= github.com/sina-ghaderi/rabbitio v0.0.0-20220730151941-9ce26f4f872e h1:ur8uMsPIFG3i4Gi093BQITvwH9znsz2VUZmnmwHvpIo= github.com/sina-ghaderi/rabbitio v0.0.0-20220730151941-9ce26f4f872e/go.mod h1:+e5fBW3bpPyo+3uLo513gIUblc03egGjMM0+5GKbzK8= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM= github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae/go.mod h1:gXtu8J62kEgmN++bm9BVICuT/e8yiLI2KFobd/TRFsE= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7 h1:UNrDfkQqiEYzdMlNsVvBYOAJWZjdktqFE9tQh5BT2+4= gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7/go.mod h1:E+rxHvJG9H6PUdzq9NRG6csuLN3XUx98BfGOVWNYnXs= gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec h1:FpfFs4EhNehiVfzQttTuxanPIT43FtkkCFypIod8LHo= gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec/go.mod h1:BZ1RAoRPbCxum9Grlv5aeksu2H8BiKehBYooU2LFiOQ= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI= modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= ================================================ FILE: internal/config/base.go ================================================ package config import ( "crypto/rand" "encoding/hex" "encoding/json" "flag" "fmt" "net" "os" "path/filepath" "strconv" "strings" "github.com/bestruirui/bestsub/internal/models/config" "github.com/bestruirui/bestsub/internal/utils" ) var baseConfig = config.DefaultBase() func init() { execPath, err := os.Executable() if err != nil { panic(fmt.Errorf("获取可执行文件路径失败: %v", err)) } execDir := filepath.Dir(execPath) defaultConfigPath := filepath.Join(execDir, "config.json") configPath := flag.String("c", defaultConfigPath, "config file path") flag.Parse() if *configPath == "" { *configPath = defaultConfigPath } if !filepath.IsAbs(*configPath) { absPath, err := filepath.Abs(*configPath) if err != nil { panic(fmt.Errorf("无法转换为绝对路径: %v", err)) } *configPath = absPath } if err := loadFromFile(&baseConfig, *configPath); err != nil { if os.IsNotExist(err) { if err := createDefaultConfig(*configPath); err != nil { panic(fmt.Errorf("创建默认配置文件失败: %v", err)) } if err := loadFromFile(&baseConfig, *configPath); err != nil { panic(fmt.Errorf("加载默认配置文件失败: %v", err)) } } else { panic(fmt.Errorf("加载配置文件失败: %v", err)) } } setupPaths(&baseConfig, *configPath) loadFromEnv(&baseConfig) if err := validateConfig(&baseConfig); err != nil { panic(fmt.Errorf("配置验证失败: %v", err)) } } func Base() config.Base { return baseConfig } func setupPaths(config *config.Base, configPath string) { configDir := filepath.Dir(configPath) if config.Server.UIPath == "" { config.Server.UIPath = filepath.Join(configDir, "ui") } if config.Database.Path == "" { config.Database.Path = filepath.Join(configDir, "data", "bestsub.db") } if config.Log.Path == "" { config.Log.Path = filepath.Join(configDir, "log") } if config.Session.NodePath == "" { config.Session.NodePath = filepath.Join(configDir, "session", "node.session") } } func loadFromFile(config *config.Base, filePath string) error { if _, err := os.Stat(filePath); os.IsNotExist(err) { return err } data, err := os.ReadFile(filePath) if err != nil { return fmt.Errorf("读取配置文件失败: %v", err) } if err := json.Unmarshal(data, config); err != nil { return fmt.Errorf("解析配置文件失败: %v", err) } return nil } func loadFromEnv(config *config.Base) { if port := os.Getenv("BESTSUB_SERVER_PORT"); port != "" { if p, err := parsePort(port); err == nil { config.Server.Port = p } } if host := os.Getenv("BESTSUB_SERVER_HOST"); host != "" { config.Server.Host = host } if dbPath := os.Getenv("BESTSUB_DATABASE_PATH"); dbPath != "" { config.Database.Path = dbPath } if dbType := os.Getenv("BESTSUB_DATABASE_TYPE"); dbType != "" { config.Database.Type = dbType } if logLevel := os.Getenv("BESTSUB_LOG_LEVEL"); logLevel != "" { config.Log.Level = logLevel } if logOutput := os.Getenv("BESTSUB_LOG_OUTPUT"); logOutput != "" { config.Log.Output = logOutput } if logDir := os.Getenv("BESTSUB_LOG_DIR"); logDir != "" { config.Log.Path = logDir } if jwtSecret := os.Getenv("BESTSUB_JWT_SECRET"); jwtSecret != "" { config.JWT.Secret = jwtSecret } } func parsePort(portStr string) (int, error) { port, err := strconv.Atoi(portStr) if err != nil { return 0, fmt.Errorf("无效的端口号: %s", portStr) } if port <= 0 || port > 65535 { return 0, fmt.Errorf("端口号超出范围: %d", port) } return port, nil } func createDefaultConfig(filePath string) error { dir := filepath.Dir(filePath) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("创建配置目录失败: %v", err) } bytes := make([]byte, 32) rand.Read(bytes) baseConfig.JWT.Secret = hex.EncodeToString(bytes) data, err := json.MarshalIndent(baseConfig, "", " ") if err != nil { return fmt.Errorf("序列化默认配置失败: %v", err) } if err := os.WriteFile(filePath, data, 0644); err != nil { return fmt.Errorf("写入配置文件失败: %v", err) } return nil } func validateConfig(config *config.Base) error { if err := validateServerConfig(&config.Server); err != nil { return fmt.Errorf("服务器配置验证失败: %v", err) } if err := validateDatabaseConfig(&config.Database); err != nil { return fmt.Errorf("数据库配置验证失败: %v", err) } if err := validateLogConfig(&config.Log); err != nil { return fmt.Errorf("日志配置验证失败: %v", err) } if err := validateJWTConfig(&config.JWT); err != nil { return fmt.Errorf("JWT配置验证失败: %v", err) } if err := validateSessionConfig(&config.Session); err != nil { return fmt.Errorf("会话配置验证失败: %v", err) } return nil } func validateServerConfig(config *config.ServerConfig) error { if config.Port <= 0 || config.Port > 65535 { return fmt.Errorf("端口号必须在1-65535范围内,当前值: %d", config.Port) } if config.Host == "" { return fmt.Errorf("主机地址不能为空") } if ip := net.ParseIP(config.Host); ip == nil { return fmt.Errorf("无效的主机地址格式: %s", config.Host) } return nil } func validateDatabaseConfig(config *config.DatabaseConfig) error { if config.Type == "" { return fmt.Errorf("数据库类型不能为空") } if config.Path == "" { return fmt.Errorf("数据库路径不能为空") } dir := filepath.Dir(config.Path) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("无法创建数据库目录 %s: %v", dir, err) } if !utils.IsWritableDir(dir) { return fmt.Errorf("数据库目录 %s 不可写", dir) } return nil } func validateLogConfig(config *config.LogConfig) error { validLevels := []string{"debug", "info", "warn", "error"} if !utils.Contains(validLevels, strings.ToLower(config.Level)) { return fmt.Errorf("无效的日志等级: %s,支持的等级: %v", config.Level, validLevels) } validOutputs := []string{"console", "file", "both"} if !utils.Contains(validOutputs, strings.ToLower(config.Output)) { return fmt.Errorf("无效的日志输出方式: %s,支持的方式: %v", config.Output, validOutputs) } if config.Output == "file" || config.Output == "both" { if config.Path == "" { return fmt.Errorf("日志输出到文件时,文件路径不能为空") } dir := config.Path if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("无法创建日志目录 %s: %v", dir, err) } if !utils.IsWritableDir(dir) { return fmt.Errorf("日志目录 %s 不可写", dir) } } return nil } func validateJWTConfig(config *config.JWTConfig) error { if config.Secret == "" { return fmt.Errorf("JWT密钥不能为空") } if len(config.Secret) < 16 { return fmt.Errorf("JWT密钥长度不能少于16个字符,当前长度: %d", len(config.Secret)) } if strings.Contains(config.Secret, "change-me") || config.Secret == "bestsub-jwt-secret" { return fmt.Errorf("请修改默认的JWT密钥以确保安全性") } return nil } func validateSessionConfig(config *config.SessionConfig) error { if config.NodePath == "" { return fmt.Errorf("节点会话文件路径不能为空") } dir := filepath.Dir(config.NodePath) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("无法创建会话目录 %s: %v", dir, err) } if !utils.IsWritableDir(dir) { return fmt.Errorf("会话目录 %s 不可写", dir) } return nil } ================================================ FILE: internal/core/check/check.go ================================================ package check import ( _ "github.com/bestruirui/bestsub/internal/core/check/checker" "github.com/bestruirui/bestsub/internal/models/check" "github.com/bestruirui/bestsub/internal/modules/register" "github.com/bestruirui/bestsub/internal/utils/desc" ) type Desc = desc.Data func Get(m string, c string) (check.Instance, error) { return register.Get[check.Instance]("check", m, c) } func GetTypes() []string { return register.GetList("check") } func GetInfoMap() map[string][]Desc { return register.GetInfoMap("check") } ================================================ FILE: internal/core/check/checker/alive.go ================================================ package checker import ( "context" "fmt" "net/http" "sync" "sync/atomic" "time" "gopkg.in/yaml.v3" "github.com/bestruirui/bestsub/internal/core/mihomo" "github.com/bestruirui/bestsub/internal/core/node" "github.com/bestruirui/bestsub/internal/core/task" checkModel "github.com/bestruirui/bestsub/internal/models/check" nodeModel "github.com/bestruirui/bestsub/internal/models/node" "github.com/bestruirui/bestsub/internal/modules/register" "github.com/bestruirui/bestsub/internal/utils/log" ) type Alive struct { URL string `json:"url" name:"测试链接" value:"https://www.gstatic.com/generate_204"` ExptectCode int `json:"exptect_code" name:"期望状态码" value:"204"` Thread int `json:"thread" name:"线程数" value:"100"` Timeout int `json:"timeout" name:"超时时间" value:"10" desc:"单个节点检测的超时时间(s)"` } type Result struct { AliveCount uint16 `json:"alive_count" desc:"存活节点数量"` DeadCount uint16 `json:"dead_count" desc:"死亡节点数量"` Delay uint16 `json:"delay" desc:"平均延迟"` } func (e *Alive) Init() error { return nil } func (e *Alive) Run(ctx context.Context, log *log.Logger, subID []uint16) checkModel.Result { startTime := time.Now() var nodes []nodeModel.Data var aliveCount, deadCount, totalDelay int64 if len(subID) == 0 { nodes = node.GetAll() } else { nodes = *node.GetBySubId(subID) } threads := e.Thread if threads <= 0 || threads > len(nodes) { threads = len(nodes) } if threads > task.MaxThread() { threads = task.MaxThread() } if threads == 0 || len(nodes) == 0 { log.Warnf("alive check task failed, no nodes") return checkModel.Result{ Msg: "no nodes", LastRun: time.Now(), Duration: time.Since(startTime).Milliseconds(), } } sem := make(chan struct{}, threads) defer close(sem) var wg sync.WaitGroup for _, nd := range nodes { sem <- struct{}{} wg.Add(1) n := nd task.Submit(func() { defer func() { <-sem wg.Done() }() var raw map[string]any if err := yaml.Unmarshal(n.Raw, &raw); err != nil { log.Warnf("yaml.Unmarshal failed: %v", err) return } start := time.Now() alive := e.detect(ctx, raw) if alive { log.Debugf("Node %s is alive ✔", raw["name"].(string)) atomic.AddInt64(&aliveCount, 1) n.Info.SetAliveStatus(nodeModel.Alive, true) n.Info.Delay.Update(uint16(time.Since(start).Milliseconds())) log.Debugf("Node %s delay: %dms", raw["name"].(string), n.Info.Delay.Average()) atomic.AddInt64(&totalDelay, int64(n.Info.Delay.Average())) } else { log.Debugf("Node %s is dead ✘", raw["name"].(string)) atomic.AddInt64(&deadCount, 1) n.Info.SetAliveStatus(nodeModel.Alive, false) n.Info.Delay.Update(uint16(65535)) } }) } wg.Wait() avgDelay := int64(0) if aliveCount > 0 { avgDelay = totalDelay / aliveCount } log.Debugf("alive check task end, alive: %d, dead: %d, average delay: %dms", aliveCount, deadCount, avgDelay) return checkModel.Result{ Msg: fmt.Sprintf("success, alive: %d, dead: %d, average delay: %dms", aliveCount, deadCount, avgDelay), LastRun: time.Now(), Duration: time.Since(startTime).Milliseconds(), Extra: map[string]any{ "alive": aliveCount, "dead": deadCount, "delay": avgDelay, }, } } func (e *Alive) detect(ctx context.Context, raw map[string]any) bool { client := mihomo.Proxy(raw) if client == nil { return false } client.Timeout = time.Duration(e.Timeout) * time.Second defer client.Release() request, err := http.NewRequestWithContext(ctx, "GET", e.URL, nil) if err != nil { return false } response, err := client.Do(request) if err != nil { return false } defer response.Body.Close() return response.StatusCode == e.ExptectCode } func init() { register.Check(&Alive{}) } ================================================ FILE: internal/core/check/checker/country.go ================================================ package checker import ( "context" "sync" "time" "gopkg.in/yaml.v3" "github.com/bestruirui/bestsub/internal/core/mihomo" "github.com/bestruirui/bestsub/internal/core/node" "github.com/bestruirui/bestsub/internal/core/task" checkModel "github.com/bestruirui/bestsub/internal/models/check" nodeModel "github.com/bestruirui/bestsub/internal/models/node" "github.com/bestruirui/bestsub/internal/modules/country" "github.com/bestruirui/bestsub/internal/modules/register" "github.com/bestruirui/bestsub/internal/utils/log" ) type Country struct { Thread int `json:"thread" name:"线程数" value:"100"` Timeout int `json:"timeout" name:"超时时间" value:"10" desc:"单个节点检测的超时时间(s)"` } func (e *Country) Init() error { return nil } func (e *Country) Run(ctx context.Context, log *log.Logger, subID []uint16) checkModel.Result { startTime := time.Now() var nodes []nodeModel.Data if len(subID) == 0 { nodes = node.GetAll() } else { nodes = *node.GetBySubId(subID) } threads := e.Thread if threads <= 0 || threads > len(nodes) { threads = len(nodes) } if threads > task.MaxThread() { threads = task.MaxThread() } if threads == 0 { log.Warnf("country check task failed, no nodes") return checkModel.Result{ Msg: "no nodes", LastRun: time.Now(), Duration: time.Since(startTime).Milliseconds(), } } sem := make(chan struct{}, threads) defer close(sem) var wg sync.WaitGroup for _, nd := range nodes { if nd.Info.AliveStatus&nodeModel.Country != 0 { continue } sem <- struct{}{} wg.Add(1) n := nd task.Submit(func() { defer func() { <-sem wg.Done() }() var raw map[string]any if err := yaml.Unmarshal(n.Raw, &raw); err != nil { log.Warnf("yaml.Unmarshal failed: %v", err) return } client := mihomo.Proxy(raw) if client == nil { return } client.Timeout = time.Duration(e.Timeout) * time.Second defer client.Release() countryCode := country.GetCode(ctx, client.Client) if countryCode != "" { n.Info.Country = countryCode n.Info.SetAliveStatus(nodeModel.Country, true) } else { n.Info.SetAliveStatus(nodeModel.Country, false) } }) } wg.Wait() return checkModel.Result{ Msg: "success", LastRun: time.Now(), Duration: time.Since(startTime).Milliseconds(), } } func init() { register.Check(&Country{}) } ================================================ FILE: internal/core/check/checker/speed.go ================================================ package checker import ( "context" "fmt" "io" "net/http" "sync" "time" "gopkg.in/yaml.v3" "github.com/bestruirui/bestsub/internal/core/mihomo" "github.com/bestruirui/bestsub/internal/core/node" "github.com/bestruirui/bestsub/internal/core/system" "github.com/bestruirui/bestsub/internal/core/task" checkModel "github.com/bestruirui/bestsub/internal/models/check" nodeModel "github.com/bestruirui/bestsub/internal/models/node" "github.com/bestruirui/bestsub/internal/modules/register" "github.com/bestruirui/bestsub/internal/utils/log" ) const mbToBytes = 1024 * 1024 type Speed struct { Thread int `json:"thread" name:"线程数" value:"5"` Timeout int `json:"timeout" name:"超时时间" value:"60" desc:"单个节点检测的超时时间(s)"` Download bool `json:"download" name:"下载测试" value:"true"` DownloadSkip bool `json:"download_skip" name:"是否跳过已经有下载速度的节点" value:"false"` DownloadUrl string `json:"download_url" name:"测试链接" value:"https://speed.cloudflare.com/__down?bytes=104857600" desc:"最好自定义一个测试链接,部分节点可能屏蔽此默认链接"` DownloadSize int64 `json:"download_size" name:"下载大小" value:"100" desc:"到达指定大小后停止测速(MB)"` DownloadSpeed int64 `json:"download_speed" name:"下载速度" value:"1" desc:"下载速度达到指定值并且达到指定个数后停止测速(KB/s)"` DownloadCount int `json:"download_count" name:"节点个数" value:"5" desc:"符合下载速度的节点个数,满足后停止测试"` Upload bool `json:"upload" name:"上传测试" value:"false"` UploadSkip bool `json:"upload_skip" name:"是否跳过已经有上传速度的节点" value:"false"` UploadUrl string `json:"upload_url" name:"上传链接" value:"https://speed.cloudflare.com/__up" desc:"最好自定义一个测试链接,部分节点可能屏蔽此默认链接"` UploadSize int64 `json:"upload_size" name:"上传大小" value:"100" desc:"到达指定大小后停止测速(MB)"` UploadSpeed int64 `json:"upload_speed" name:"上传速度" value:"1" desc:"上传速度达到指定值并且达到指定个数后停止测速(KB/s)"` UploadCount int `json:"upload_count" name:"节点个数" value:"5" desc:"符合上传速度的节点个数,满足后停止测试"` } func (e *Speed) Init() error { return nil } func (e *Speed) Run(ctx context.Context, log *log.Logger, subID []uint16) checkModel.Result { startTime := time.Now() var nodes []nodeModel.Data if len(subID) == 0 { nodes = node.GetAll() } else { nodes = *node.GetBySubId(subID) } threads := e.Thread if threads <= 0 || threads > len(nodes) { threads = len(nodes) } if threads > task.MaxThread() { threads = task.MaxThread() } if threads == 0 { log.Warnf("speed check task failed, no nodes") return checkModel.Result{ Msg: "no nodes", LastRun: time.Now(), Duration: time.Since(startTime).Milliseconds(), } } sem := make(chan struct{}, threads) defer close(sem) var downloadCount, uploadCount int var wg sync.WaitGroup for _, nd := range nodes { sem <- struct{}{} wg.Add(1) n := nd task.Submit(func() { defer func() { <-sem wg.Done() }() var raw map[string]any if err := yaml.Unmarshal(n.Raw, &raw); err != nil { log.Warnf("yaml.Unmarshal failed: %v", err) return } client := mihomo.Proxy(raw) if client == nil { return } defer client.Release() client.Timeout = time.Duration(e.Timeout) * time.Second if e.Download && downloadCount < e.DownloadCount && (!e.DownloadSkip || n.Info.SpeedDown.Average() == 0) { speed := e.download(ctx, client.Client) if speed > 0 { n.Info.SpeedDown.Update(uint32(speed)) log.Debugf("node %s download speed: %d", raw["name"], speed) } if speed > e.DownloadSpeed { downloadCount++ } } client.Timeout = time.Duration(e.Timeout) * time.Second if e.Upload && uploadCount < e.UploadCount && (!e.UploadSkip || n.Info.SpeedUp.Average() == 0) { speed := e.upload(ctx, client.Client) if speed > 0 { n.Info.SpeedUp.Update(uint32(speed)) log.Debugf("node %s upload speed: %d", raw["name"], speed) } if speed > e.UploadSpeed { uploadCount++ } } }) } wg.Wait() return checkModel.Result{ Msg: fmt.Sprintf("success, download count: %d, upload count: %d", downloadCount, uploadCount), LastRun: time.Now(), Duration: time.Since(startTime).Milliseconds(), } } func (e *Speed) download(ctx context.Context, client *http.Client) int64 { request, err := http.NewRequestWithContext(ctx, "GET", e.DownloadUrl, nil) if err != nil { return 0 } response, err := client.Do(request) if err != nil { return 0 } defer response.Body.Close() startTime := time.Now() limitReader := io.LimitReader(response.Body, e.DownloadSize*mbToBytes) bytes, _ := io.Copy(io.Discard, limitReader) duration := time.Since(startTime).Milliseconds() if duration <= 0 || bytes <= 0 { return 0 } system.AddDownloadBytes(uint64(bytes)) return bytes / duration } func (e *Speed) upload(ctx context.Context, client *http.Client) int64 { uploadBytes := e.UploadSize * mbToBytes reader := &trackingZeroReader{remaining: uploadBytes} request, err := http.NewRequestWithContext(ctx, "POST", e.UploadUrl, reader) if err != nil { return 0 } request.ContentLength = uploadBytes startTime := time.Now() response, err := client.Do(request) if err != nil { return 0 } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { return 0 } io.Copy(io.Discard, response.Body) duration := time.Since(startTime).Milliseconds() if duration <= 0 || reader.bytesRead <= 0 { return 0 } system.AddUploadBytes(uint64(reader.bytesRead)) return reader.bytesRead / duration } type trackingZeroReader struct { remaining int64 bytesRead int64 } func (r *trackingZeroReader) Read(p []byte) (n int, err error) { if r.remaining <= 0 { return 0, io.EOF } if int64(len(p)) > r.remaining { n = int(r.remaining) } else { n = len(p) } clear(p[:n]) r.remaining -= int64(n) r.bytesRead += int64(n) return n, nil } func init() { register.Check(&Speed{}) } ================================================ FILE: internal/core/check/checker/tiktok.go ================================================ package checker import ( "bytes" "context" "io" "net/http" "sync" "time" "gopkg.in/yaml.v3" "github.com/bestruirui/bestsub/internal/core/mihomo" "github.com/bestruirui/bestsub/internal/core/node" "github.com/bestruirui/bestsub/internal/core/task" "github.com/bestruirui/bestsub/internal/models/check" nodeModel "github.com/bestruirui/bestsub/internal/models/node" "github.com/bestruirui/bestsub/internal/modules/register" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/bestruirui/bestsub/internal/utils/ua" ) type TikTok struct { Thread int `json:"thread" name:"线程数" value:"200"` Timeout int `json:"timeout" name:"超时时间" value:"10" desc:"单个节点检测的超时时间(s)"` } func (e *TikTok) Init() error { return nil } func (e *TikTok) Run(ctx context.Context, log *log.Logger, subID []uint16) check.Result { startTime := time.Now() var nodes []nodeModel.Data if len(subID) == 0 { nodes = node.GetAll() } else { nodes = *node.GetBySubId(subID) } threads := e.Thread if threads <= 0 || threads > len(nodes) { threads = len(nodes) } if threads > task.MaxThread() { threads = task.MaxThread() } if threads == 0 || len(nodes) == 0 { log.Warnf("tiktok check task failed, no nodes") return check.Result{ Msg: "no nodes", LastRun: time.Now(), Duration: time.Since(startTime).Milliseconds(), } } sem := make(chan struct{}, threads) defer close(sem) var wg sync.WaitGroup for _, nd := range nodes { sem <- struct{}{} wg.Add(1) n := nd task.Submit(func() { defer func() { <-sem wg.Done() }() var raw map[string]any if err := yaml.Unmarshal(n.Raw, &raw); err != nil { log.Warnf("yaml.Unmarshal failed: %v", err) return } switch e.detectTikTok(ctx, raw) { case 1: n.Info.SetAliveStatus(nodeModel.TikTok, true) case 2: n.Info.SetAliveStatus(nodeModel.TikTokIDC, true) default: n.Info.SetAliveStatus(nodeModel.TikTok, false) n.Info.SetAliveStatus(nodeModel.TikTokIDC, false) } }) } wg.Wait() log.Debugf("tiktok check task end") return check.Result{ Msg: "success", LastRun: time.Now(), Duration: time.Since(startTime).Milliseconds(), } } func (e *TikTok) detectTikTok(ctx context.Context, raw map[string]any) uint8 { client := mihomo.Proxy(raw) if client == nil { return 0 } client.Timeout = time.Duration(e.Timeout) * time.Second defer client.Release() req, err := http.NewRequestWithContext(ctx, "GET", "https://www.tiktok.com/", nil) if err != nil { return 0 } ua.SetHeader(req) resp, err := client.Do(req) if err != nil { return 0 } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return 0 } if extractRegion(body) { return 1 } req, err = http.NewRequestWithContext(ctx, "GET", "https://www.tiktok.com/api/passport/web/region/get/", nil) if err != nil { return 0 } ua.SetHeader(req) req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9") req.Header.Set("Accept-Language", "en") resp, err = client.Do(req) if err != nil { return 0 } defer resp.Body.Close() body, err = io.ReadAll(resp.Body) if err != nil { return 0 } if extractRegion(body) { return 2 } return 0 } func extractRegion(html []byte) bool { return bytes.Contains(html, []byte(`"region":`)) } func init() { register.Check(&TikTok{}) } ================================================ FILE: internal/core/cron/check.go ================================================ package cron import ( "context" "encoding/json" "fmt" "time" "github.com/bestruirui/bestsub/internal/core/check" "github.com/bestruirui/bestsub/internal/core/node" "github.com/bestruirui/bestsub/internal/database/op" checkModel "github.com/bestruirui/bestsub/internal/models/check" "github.com/bestruirui/bestsub/internal/utils/generic" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/robfig/cron/v3" ) var checkFunc = generic.MapOf[uint16, cronFunc]{} var checkScheduled = generic.MapOf[uint16, cron.EntryID]{} var checkRunning = generic.MapOf[uint16, context.CancelFunc]{} func CheckLoad() { checkData, err := op.GetCheckList() if err != nil { log.Errorf("failed to load sub data: %v", err) return } for _, data := range checkData { CheckAdd(&data) } } func CheckAdd(data *checkModel.Data) error { var taskConfig checkModel.Task if err := json.Unmarshal([]byte(data.Task), &taskConfig); err != nil { log.Errorf("failed to unmarshal task config: %v", err) return err } checkFunc.Store(data.ID, cronFunc{ fn: func() { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(taskConfig.Timeout)*time.Minute) checkRunning.Store(data.ID, cancel) defer func() { cancel() checkRunning.Delete(data.ID) }() logger, err := log.NewTaskLogger("check", data.ID, taskConfig.LogLevel, taskConfig.LogWriteFile) if err != nil { log.Errorf("failed to create logger: %v", err) return } go func() { <-ctx.Done() logger.Close() }() checker, err := check.Get(taskConfig.Type, data.Config) if err != nil { log.Errorf("failed to get execer: %v", err) return } log.Infof("%s task %d start", taskConfig.Type, data.ID) var result checkModel.Result if taskConfig.SubIdExclude { result = checker.Run(ctx, logger, node.GetBySubIdExclude(taskConfig.SubID)) } else { result = checker.Run(ctx, logger, taskConfig.SubID) } log.Infof("%s task %d end", taskConfig.Type, data.ID) op.UpdateCheckResult(data.ID, result) node.RefreshInfo() }, cronExpr: taskConfig.CronExpr, }) if data.Enable { CheckEnable(data.ID) } return nil } func CheckUpdate(data *checkModel.Data) error { CheckRemove(data.ID) CheckAdd(data) return nil } func CheckRun(id uint16) error { if ft, ok := checkFunc.Load(id); ok { go ft.fn() return nil } else { return fmt.Errorf("check task %d not found", id) } } func CheckEnable(id uint16) error { if _, ok := checkScheduled.Load(id); ok { log.Warnf("check task %d already scheduled", id) return nil } if ft, ok := checkFunc.Load(id); ok { entryID, err := scheduler.AddFunc(ft.cronExpr, ft.fn) if err != nil { log.Errorf("failed to add task: %v", err) return err } checkScheduled.Store(id, entryID) } return nil } func CheckDisable(id uint16) error { if entryID, ok := checkScheduled.Load(id); ok { scheduler.Remove(entryID) checkScheduled.Delete(id) if cancel, ok := checkRunning.Load(id); ok { cancel() checkRunning.Delete(id) } } return nil } func CheckRemove(id uint16) error { if entryID, ok := checkScheduled.Load(id); ok { scheduler.Remove(entryID) checkScheduled.Delete(id) checkFunc.Delete(id) if cancel, ok := checkRunning.Load(id); ok { cancel() checkRunning.Delete(id) } } return nil } func CheckStop(id uint16) error { if cancel, ok := checkRunning.Load(id); ok { cancel() checkRunning.Delete(id) } return nil } func CheckStatus(id uint16) string { if _, ok := checkRunning.Load(id); ok { return RunningStatus } if _, ok := checkScheduled.Load(id); ok { return ScheduledStatus } if _, ok := checkFunc.Load(id); ok { return PendingStatus } return DisabledStatus } ================================================ FILE: internal/core/cron/cron.go ================================================ package cron import ( "time" "github.com/robfig/cron/v3" ) type cronFunc struct { fn func() cronExpr string } var scheduler = cron.New(cron.WithLocation(time.Local)) const ( RunningStatus = "running" ScheduledStatus = "scheduled" PendingStatus = "pending" DisabledStatus = "disabled" ) func Start() { scheduler.Start() } func Stop() { scheduler.Stop() } ================================================ FILE: internal/core/cron/fetch.go ================================================ package cron import ( "context" "encoding/json" "math/rand" "time" "github.com/bestruirui/bestsub/internal/core/fetch" "github.com/bestruirui/bestsub/internal/database/op" subModel "github.com/bestruirui/bestsub/internal/models/sub" "github.com/bestruirui/bestsub/internal/utils/generic" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/robfig/cron/v3" ) var fetchFunc = generic.MapOf[uint16, cronFunc]{} var fetchScheduled = generic.MapOf[uint16, cron.EntryID]{} var fetchRunning = generic.MapOf[uint16, context.CancelFunc]{} func FetchLoad() { subData, err := op.GetSubList(context.Background()) if err != nil { log.Errorf("failed to load sub data: %v", err) return } for _, data := range subData { FetchAdd(&data) } } func FetchAdd(data *subModel.Data) error { fetchFunc.Store(data.ID, cronFunc{ fn: func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) fetchRunning.Store(data.ID, cancel) defer func() { cancel() fetchRunning.Delete(data.ID) }() result := fetch.Do(ctx, data.ID, data.Config) op.UpdateSubResult(ctx, data.ID, result) sub, err := op.GetSubByID(ctx, data.ID) if err != nil { log.Warnf("failed to get sub by id: %v", err) return } if !sub.Enable { FetchDisable(data.ID) log.Infof("fetch task %d auto disable", data.ID) } }, cronExpr: data.CronExpr, }) if data.Enable { FetchEnable(data.ID) } return nil } func FetchRun(subID uint16) subModel.Result { if ft, ok := fetchFunc.Load(subID); ok { ft.fn() } else { log.Warnf("fetch task %d not found", subID) return subModel.Result{ Msg: "fetch task not found", LastRun: time.Now(), } } sub, err := op.GetSubByID(context.Background(), subID) if err != nil { log.Warnf("failed to get sub by id: %v", err) return subModel.Result{ Msg: "fetch task not found", LastRun: time.Now(), } } var result subModel.Result if err := json.Unmarshal([]byte(sub.Result), &result); err != nil { log.Warnf("failed to unmarshal sub result: %v", err) return subModel.Result{ Msg: "failed to unmarshal sub result", LastRun: time.Now(), } } return result } func FetchEnable(subID uint16) error { if _, ok := fetchScheduled.Load(subID); ok { log.Warnf("fetch task %d already scheduled", subID) return nil } if ft, ok := fetchFunc.Load(subID); ok { entryID, err := scheduler.AddFunc(ft.cronExpr, func() { time.Sleep(time.Duration(rand.Intn(100)) * time.Second) ft.fn() }) if err != nil { log.Errorf("failed to add task: %v", err) return err } fetchScheduled.Store(subID, entryID) } return nil } func FetchDisable(subID uint16) error { if entryID, ok := fetchScheduled.Load(subID); ok { scheduler.Remove(entryID) fetchScheduled.Delete(subID) if cancel, ok := fetchRunning.Load(subID); ok { cancel() fetchRunning.Delete(subID) } } return nil } func FetchRemove(subID uint16) error { if entryID, ok := fetchScheduled.Load(subID); ok { scheduler.Remove(entryID) fetchScheduled.Delete(subID) fetchFunc.Delete(subID) if cancel, ok := fetchRunning.Load(subID); ok { cancel() fetchRunning.Delete(subID) } } return nil } func FetchStop(subID uint16) error { if cancel, ok := fetchRunning.Load(subID); ok { cancel() fetchRunning.Delete(subID) } return nil } func FetchUpdate(data *subModel.Data) error { FetchRemove(data.ID) FetchAdd(data) return nil } func FetchStatus(subID uint16) string { if _, ok := fetchRunning.Load(subID); ok { return RunningStatus } if _, ok := fetchScheduled.Load(subID); ok { return ScheduledStatus } if _, ok := fetchFunc.Load(subID); ok { return PendingStatus } return DisabledStatus } ================================================ FILE: internal/core/fetch/fetch.go ================================================ package fetch import ( "bytes" "context" "encoding/json" "io" "net/http" "slices" "strings" "time" "github.com/bestruirui/bestsub/internal/core/mihomo" "github.com/bestruirui/bestsub/internal/core/node" "github.com/bestruirui/bestsub/internal/core/subconv" "github.com/bestruirui/bestsub/internal/database/op" nodeModel "github.com/bestruirui/bestsub/internal/models/node" "github.com/bestruirui/bestsub/internal/models/setting" subModel "github.com/bestruirui/bestsub/internal/models/sub" "github.com/bestruirui/bestsub/internal/utils/log" "gopkg.in/yaml.v3" ) func Do(ctx context.Context, subID uint16, config string) subModel.Result { startTime := time.Now() retry := 0 var subConfig subModel.Config if err := json.Unmarshal([]byte(config), &subConfig); err != nil { log.Warnf("fetch task %d failed: %v", subID, err) return createFailureResult(err.Error(), startTime) } log.Debugf("fetch task %d started", subID) client := mihomo.Default(subConfig.Proxy) if client == nil { log.Warnf("fetch task %d failed: proxy config error", subID) return createFailureResult("proxy config error", startTime) } defer client.Release() for retry < 3 { time.Sleep(time.Duration(retry) * time.Second) retry++ client.Timeout = time.Duration(subConfig.Timeout) * time.Second req, err := http.NewRequestWithContext(ctx, "GET", subConfig.Url, nil) if err != nil { log.Warnf("fetch task %d failed: %v", subID, err) continue } resp, err := client.Do(req) if err != nil { log.Warnf("fetch task %d failed: %v", subID, err) continue } defer resp.Body.Close() content, err := io.ReadAll(resp.Body) if err != nil { log.Warnf("fetch task %d failed: %v", subID, err) continue } contentStr := subconv.ConvertData(string(content), "mihomo") content = []byte(contentStr) globalProtocolFilterEnable := op.GetSettingBool(setting.NODE_PROTOCOL_FILTER_ENABLE) globalProtocolFilterMode := op.GetSettingBool(setting.NODE_PROTOCOL_FILTER_MODE) globalProtocolFilter := strings.Split(op.GetSettingStr(setting.NODE_PROTOCOL_FILTER), ",") var nodes []nodeModel.Base var unique nodeModel.UniqueKey lines := bytes.Split(content, []byte("\n")) lines = lines[1:] for _, line := range lines { if len(line) == 0 { continue } line = line[4:] if err := yaml.Unmarshal(line, &unique); err != nil { continue } if subConfig.ProtocolFilterEnable { if subConfig.ProtocolFilterMode { if !slices.Contains(subConfig.ProtocolFilter, unique.Type) { continue } } else { if slices.Contains(subConfig.ProtocolFilter, unique.Type) { continue } } } else { if globalProtocolFilterEnable { if globalProtocolFilterMode { if !slices.Contains(globalProtocolFilter, unique.Type) { log.Debugf("全局协议过滤启用,协议包含模式 丢弃协议: %v", unique.Type) continue } } else { if slices.Contains(globalProtocolFilter, unique.Type) { log.Debugf("全局协议过滤启用,协议排除模式 丢弃协议: %v", unique.Type) continue } } } } nodes = append(nodes, nodeModel.Base{ Raw: line, SubId: subID, UniqueKey: unique.Gen(), }) } count := len(nodes) node.Add(&nodes) log.Infof("fetch task %d completed, raw node count: %d, duration: %dms", subID, count, uint16(time.Since(startTime).Milliseconds())) return createSuccessResult(uint32(count), startTime, count == 0) } return createFailureResult("fetch task failed", startTime) } func createFailureResult(msg string, startTime time.Time) subModel.Result { return subModel.Result{ Success: 0, Fail: 1, Msg: msg, LastRun: time.Now(), Duration: uint16(time.Since(startTime).Milliseconds()), } } func createSuccessResult(count uint32, startTime time.Time, nodeNull bool) subModel.Result { nodeNullCount := uint16(0) if nodeNull { nodeNullCount = 1 } return subModel.Result{ Success: 1, Fail: 0, NodeNullCount: nodeNullCount, Msg: "sub updated successfully", RawCount: count, LastRun: time.Now(), Duration: uint16(time.Since(startTime).Milliseconds()), } } ================================================ FILE: internal/core/mihomo/mihomo.go ================================================ package mihomo import ( "context" "net" "net/http" "net/url" "strconv" "sync" "time" "github.com/bestruirui/bestsub/internal/database/op" "github.com/bestruirui/bestsub/internal/models/setting" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/metacubex/mihomo/adapter" "github.com/metacubex/mihomo/constant" ) type HC struct { *http.Client proxy constant.Proxy } var clientPool = sync.Pool{ New: func() interface{} { return &http.Client{ Timeout: 300 * time.Second, } }, } var transportPool = sync.Pool{ New: func() interface{} { return &http.Transport{ DisableKeepAlives: true, TLSHandshakeTimeout: 30 * time.Second, ExpectContinueTimeout: 10 * time.Second, ResponseHeaderTimeout: 30 * time.Second, } }, } func parsePort(portStr string) (uint16, error) { port, err := strconv.ParseUint(portStr, 10, 16) if err != nil { return 0, err } return uint16(port), nil } func Default(useProxy bool) *HC { if !useProxy || !op.GetSettingBool(setting.PROXY_ENABLE) { return direct() } proxyUrl := op.GetSettingStr(setting.PROXY_URL) if proxyUrl == "" { log.Warnf("proxy url is empty") return direct() } parsed, err := url.Parse(proxyUrl) if err != nil { log.Warnf("parse proxy url failed: %v", err) return direct() } host, portStr, err := net.SplitHostPort(parsed.Host) if err != nil { log.Warnf("split host port failed: %v", err) return direct() } portInt, err := parsePort(portStr) if err != nil { log.Warnf("parse port failed: %v", err) return direct() } proxyConfig := map[string]any{ "name": "proxy", "server": host, "port": portInt, "username": parsed.User.Username(), } if password, ok := parsed.User.Password(); ok { proxyConfig["password"] = password } switch parsed.Scheme { case "socks5": proxyConfig["type"] = "socks5" case "http": proxyConfig["type"] = "http" case "https": proxyConfig["type"] = "http" proxyConfig["tls"] = true default: log.Warnf("unsupported proxy scheme: %s", parsed.Scheme) return direct() } return Proxy(proxyConfig) } func direct() *HC { var directProxy = map[string]any{ "name": "direct", "type": "direct", } return Proxy(directProxy) } func Proxy(raw map[string]any) *HC { if raw == nil { log.Warnf("proxy config is nil") return nil } proxy, err := adapter.ParseProxy(raw) if err != nil { if proxy != nil { proxy.Close() } log.Debugf("parse proxy failed: %v raw: %v", err, raw) return nil } client := clientPool.Get().(*http.Client) transport := transportPool.Get().(*http.Transport) transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { host, portStr, err := net.SplitHostPort(addr) if err != nil { return nil, err } u16Port, err := parsePort(portStr) if err != nil { log.Warnf("parse port failed, using port 0: %v", err) u16Port = 0 } return proxy.DialContext(ctx, &constant.Metadata{ Host: host, DstPort: u16Port, }) } client.Transport = transport return &HC{Client: client, proxy: proxy} } func (h *HC) Release() { if h.Client == nil { return } if h.proxy != nil { h.proxy.Close() h.proxy = nil } if transport, ok := h.Transport.(*http.Transport); ok { transport.DialContext = nil transport.TLSClientConfig = nil transport.Proxy = nil transport.CloseIdleConnections() transportPool.Put(transport) } h.Transport = nil h.Timeout = 300 * time.Second h.CheckRedirect = nil h.Jar = nil clientPool.Put(h.Client) } ================================================ FILE: internal/core/node/exist.go ================================================ package node import "sync" type exist struct { mu sync.RWMutex data map[uint64]struct{} } func NewExist(size int) *exist { return &exist{data: make(map[uint64]struct{}, size)} } func (k *exist) Exist(key uint64) bool { k.mu.RLock() _, exists := k.data[key] k.mu.RUnlock() return exists } func (k *exist) Add(key uint64) { k.mu.Lock() k.data[key] = struct{}{} k.mu.Unlock() } func (k *exist) Remove(key uint64) { k.mu.Lock() delete(k.data, key) k.mu.Unlock() } ================================================ FILE: internal/core/node/info.go ================================================ package node import nodeModel "github.com/bestruirui/bestsub/internal/models/node" func RefreshInfo() { refreshMutex.Lock() defer refreshMutex.Unlock() for k := range subAggBuf { delete(subAggBuf, k) } for k := range countryAggBuf { delete(countryAggBuf, k) } poolMutex.RLock() for _, n := range pool { s := subAggBuf[n.Base.SubId] if s == nil { s = &infoSums{} subAggBuf[n.Base.SubId] = s } s.count++ s.sumSpeedUp += uint64(n.Info.SpeedUp.Average()) s.sumSpeedDown += uint64(n.Info.SpeedDown.Average()) s.sumDelay += uint64(n.Info.Delay.Average()) s.sumRisk += uint64(n.Info.Risk) c := countryAggBuf[n.Info.Country] if c == nil { c = &infoSums{} countryAggBuf[n.Info.Country] = c } c.count++ c.sumSpeedUp += uint64(n.Info.SpeedUp.Average()) c.sumSpeedDown += uint64(n.Info.SpeedDown.Average()) c.sumDelay += uint64(n.Info.Delay.Average()) c.sumRisk += uint64(n.Info.Risk) } poolMutex.RUnlock() for k := range subInfoMap { delete(subInfoMap, k) } for k := range countryInfoMap { delete(countryInfoMap, k) } for subID, s := range subAggBuf { if s.count == 0 { continue } subInfoMap[subID] = nodeModel.SimpleInfo{ Count: s.count, SpeedUp: uint32(s.sumSpeedUp / uint64(s.count)), SpeedDown: uint32(s.sumSpeedDown / uint64(s.count)), Delay: uint16(s.sumDelay / uint64(s.count)), Risk: uint8(s.sumRisk / uint64(s.count)), } } for country, c := range countryAggBuf { if c.count == 0 { continue } countryInfoMap[country] = nodeModel.SimpleInfo{ Count: c.count, SpeedUp: uint32(c.sumSpeedUp / uint64(c.count)), SpeedDown: uint32(c.sumSpeedDown / uint64(c.count)), Delay: uint16(c.sumDelay / uint64(c.count)), Risk: uint8(c.sumRisk / uint64(c.count)), } } } ================================================ FILE: internal/core/node/node.go ================================================ package node import ( "bytes" "context" "encoding/gob" "net/http" "os" "path" "slices" "sort" "time" "gopkg.in/yaml.v3" "github.com/bestruirui/bestsub/internal/config" "github.com/bestruirui/bestsub/internal/core/mihomo" "github.com/bestruirui/bestsub/internal/core/task" "github.com/bestruirui/bestsub/internal/database/op" nodeModel "github.com/bestruirui/bestsub/internal/models/node" "github.com/bestruirui/bestsub/internal/models/setting" "github.com/bestruirui/bestsub/internal/utils/log" ) func InitNodePool(size int) { pool = make([]nodeModel.Data, 0, size) nodeExist = NewExist(size) nodeProcess = NewExist(size) sessionFile := config.Base().Session.NodePath if _, err := os.Stat(sessionFile); os.IsNotExist(err) { return } file, err := os.Open(sessionFile) if err != nil { return } defer file.Close() decoder := gob.NewDecoder(file) if err := decoder.Decode(&pool); err != nil { log.Warnf("restore node pool failed: %v", err) os.Remove(sessionFile) return } for _, node := range pool { nodeExist.Add(node.Base.UniqueKey) } } func CloseNodePool() error { var buf bytes.Buffer encoder := gob.NewEncoder(&buf) if err := encoder.Encode(pool); err != nil { log.Warnf("save node pool failed: %v", err) return err } filePath := config.Base().Session.NodePath dir := path.Dir(filePath) if _, err := os.Stat(dir); os.IsNotExist(err) { os.MkdirAll(dir, 0755) } if os.WriteFile(filePath, buf.Bytes(), 0600) != nil { log.Warnf("node pool save failed") } log.Debugf("node pool saved") return nil } type nameNode struct { Name string } func Add(node *[]nodeModel.Base) int { var nodesToProcess []nodeModel.Base for _, n := range *node { var nameNode nameNode if err := yaml.Unmarshal(n.Raw, &nameNode); err != nil { log.Warnf("yaml.Unmarshal failed: %v", err) continue } if !nodeExist.Exist(n.UniqueKey) && !nodeProcess.Exist(n.UniqueKey) { nodeProcess.Add(n.UniqueKey) nodesToProcess = append(nodesToProcess, n) log.Debugf("add process node: %s", nameNode.Name) } else { log.Debugf("node already exist: %s", nameNode.Name) } } log.Debugf("add %d nodes to process", len(nodesToProcess)) if len(nodesToProcess) > 0 { go func() { for _, node := range nodesToProcess { n := node // capture loop variable wgSync.Add(1) task.Submit(func() { defer wgSync.Done() defer nodeProcess.Remove(n.UniqueKey) var raw map[string]any if err := yaml.Unmarshal(n.Raw, &raw); err != nil { log.Warnf("yaml.Unmarshal failed: %v", err) return } client := mihomo.Proxy(raw) if client == nil { return } defer client.Release() ctx, cancel := context.WithTimeout(context.Background(), time.Duration(op.GetSettingInt(setting.NODE_TEST_TIMEOUT))*time.Second) defer cancel() request, err := http.NewRequestWithContext(ctx, "GET", op.GetSettingStr(setting.NODE_TEST_URL), nil) if err != nil { return } start := time.Now() response, err := client.Do(request) if err != nil { return } defer response.Body.Close() if response.StatusCode != 204 { return } var info nodeModel.Info info.Delay.Update(uint16(time.Since(start).Milliseconds())) info.SetAliveStatus(nodeModel.Alive, true) rawCopy := append([]byte(nil), n.Raw...) n.Raw = rawCopy validMutex.Lock() validNodes = append(validNodes, nodeModel.Data{ Base: n, Info: &info, }) log.Debugf("node: %s test end, Delay: %d", raw["name"].(string), info.Delay.Average()) validMutex.Unlock() }) } }() if !wgStatus { wgStatus = true go func() { time.Sleep(time.Second * 5) wgSync.Wait() mergedNodes := 0 if len(validNodes) > 0 { mergedNodes = mergeNodesToPool(validNodes) RefreshInfo() } log.Infof("Receipt successful, %d new nodes added", mergedNodes) validNodes = validNodes[:0] wgStatus = false }() } } return len(nodesToProcess) } func ForEach(fn func(node []byte)) { poolMutex.RLock() defer poolMutex.RUnlock() for _, node := range pool { fn(node.Raw) } } func GetAll() []nodeModel.Data { poolMutex.RLock() defer poolMutex.RUnlock() return pool } func GetBySubIdExclude(subId []uint16) []uint16 { poolMutex.RLock() defer poolMutex.RUnlock() var result []uint16 for _, node := range pool { if !slices.Contains(subId, node.Base.SubId) { result = append(result, node.Base.SubId) } } return result } func GetBySubId(subId []uint16) *[]nodeModel.Data { poolMutex.RLock() defer poolMutex.RUnlock() var result []nodeModel.Data for _, node := range pool { if slices.Contains(subId, node.Base.SubId) { result = append(result, node) } } return &result } func GetByFilter(filter nodeModel.Filter) *[]nodeModel.Data { poolMutex.RLock() defer poolMutex.RUnlock() var result []nodeModel.Data for _, node := range pool { if len(filter.SubId) > 0 { if filter.SubIdExclude && slices.Contains(filter.SubId, node.Base.SubId) { continue } if !filter.SubIdExclude && !slices.Contains(filter.SubId, node.Base.SubId) { continue } } if filter.AliveStatus != 0 && node.Info.AliveStatus&filter.AliveStatus != filter.AliveStatus { continue } if len(filter.Country) > 0 { if filter.CountryExclude && slices.Contains(filter.Country, node.Info.Country) { continue } if !filter.CountryExclude && !slices.Contains(filter.Country, node.Info.Country) { continue } } if filter.SpeedUpMore != 0 && node.Info.SpeedUp.Average() < filter.SpeedUpMore { continue } if filter.SpeedDownMore != 0 && node.Info.SpeedDown.Average() < filter.SpeedDownMore { continue } if filter.DelayLessThan != 0 && node.Info.Delay.Average() > filter.DelayLessThan { continue } if filter.RiskLessThan != 0 && node.Info.Risk > filter.RiskLessThan { continue } result = append(result, node) } return &result } func mergeNodesToPool(newNodes []nodeModel.Data) int { sort.Slice(newNodes, func(i, j int) bool { return newNodes[i].Info.Delay.Average() < newNodes[j].Info.Delay.Average() }) poolMutex.Lock() defer poolMutex.Unlock() poolLen := len(pool) poolCap := cap(pool) if poolLen < poolCap { remainingCap := poolCap - poolLen if len(newNodes) < remainingCap { pool = append(pool, newNodes...) for _, node := range newNodes { nodeExist.Add(node.Base.UniqueKey) } return len(newNodes) } else { pool = append(pool, newNodes[:remainingCap]...) for _, node := range newNodes[:remainingCap] { nodeExist.Add(node.Base.UniqueKey) } newNodes = newNodes[remainingCap:] } } sort.Slice(pool, func(i, j int) bool { return pool[i].Info.Delay.Average() < pool[j].Info.Delay.Average() }) newNodeIndex := 0 for i := len(pool) - 1; i >= 0 && newNodeIndex < len(newNodes); i-- { if newNodes[newNodeIndex].Info.Delay.Average() < pool[i].Info.Delay.Average() { log.Debugf("new node delay %dms < old delay %dms,merge", newNodes[newNodeIndex].Info.Delay.Average(), pool[i].Info.Delay.Average()) nodeExist.Remove(pool[i].Base.UniqueKey) pool[i] = newNodes[newNodeIndex] nodeExist.Add(newNodes[newNodeIndex].Base.UniqueKey) newNodeIndex++ } else { log.Debugf("new node delay %dms > old delay %dms,not merge", newNodes[newNodeIndex].Info.Delay.Average(), pool[i].Info.Delay.Average()) return newNodeIndex } } return 0 } func GetSubInfo(subID uint16) nodeModel.SimpleInfo { refreshMutex.Lock() defer refreshMutex.Unlock() return subInfoMap[subID] } func GetCountryInfo(country string) nodeModel.SimpleInfo { refreshMutex.Lock() defer refreshMutex.Unlock() return countryInfoMap[country] } func DeleteBySubId(subID uint16) { poolMutex.Lock() defer poolMutex.Unlock() end := len(pool) - 1 for i := 0; i <= end; { if pool[i].Base.SubId == subID { nodeExist.Remove(pool[i].Base.UniqueKey) pool[i] = pool[end] end-- } else { i++ } } pool = pool[:end+1] } ================================================ FILE: internal/core/node/var.go ================================================ package node import ( "sync" nodeModel "github.com/bestruirui/bestsub/internal/models/node" ) var ( poolMutex sync.RWMutex pool []nodeModel.Data nodeExist *exist nodeProcess *exist wgSync sync.WaitGroup wgStatus bool validNodes []nodeModel.Data validMutex sync.Mutex refreshMutex sync.Mutex subInfoMap = make(map[uint16]nodeModel.SimpleInfo) countryInfoMap = make(map[string]nodeModel.SimpleInfo) subAggBuf = make(map[uint16]*infoSums) countryAggBuf = make(map[string]*infoSums) ) type infoSums struct { sumSpeedUp uint64 sumSpeedDown uint64 sumDelay uint64 sumRisk uint64 count uint32 } ================================================ FILE: internal/core/subconv/subconv.go ================================================ package subconv import ( "bytes" "context" "encoding/json" "io" "net/http" "github.com/bestruirui/bestsub/internal/core/mihomo" "github.com/bestruirui/bestsub/internal/database/op" "github.com/bestruirui/bestsub/internal/models/setting" "github.com/enfein/mieru/v3/pkg/log" ) func ConvertData(raw string, target string) string { subStoreUrl := op.GetSettingStr(setting.SUBCONV_URL) if subStoreUrl == "" { log.Warnf("substore url is not set") return "" } client := mihomo.Default(op.GetSettingBool(setting.SUBCONV_URL_PROXY)) if client == nil { log.Warnf("failed to create http client") return "" } defer client.Release() reqBody := struct { Data string `json:"data"` Client string `json:"client"` }{ Data: raw, Client: target, } reqBodyBytes, err := json.Marshal(reqBody) if err != nil { log.Warnf("failed to marshal request body: %v", err) return "" } req, err := http.NewRequestWithContext(context.Background(), "POST", subStoreUrl+"/api/proxy/parse", bytes.NewBuffer(reqBodyBytes)) if err != nil { log.Warnf("failed to create request: %v", err) return "" } req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { log.Warnf("failed to do request: %v", err) return "" } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { log.Warnf("failed to read response body: %v", err) return "" } var respBody struct { Data struct { Parres string `json:"par_res"` } } err = json.Unmarshal(body, &respBody) if err != nil { log.Warnf("failed to unmarshal response body: %v body: %s", err, string(body)) return "" } return respBody.Data.Parres } ================================================ FILE: internal/core/system/monitor.go ================================================ package system import ( "os" "sync/atomic" "time" "github.com/bestruirui/bestsub/internal/models/system" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/shirou/gopsutil/v4/process" ) var ( startTime string uploadBytes uint64 downloadBytes uint64 ) func init() { startTime = time.Now().Format(time.RFC3339) } func AddUploadBytes(bytes uint64) { atomic.AddUint64(&uploadBytes, bytes) } func AddDownloadBytes(bytes uint64) { atomic.AddUint64(&downloadBytes, bytes) } func GetSystemInfo() *system.Info { proc, err := process.NewProcess(int32(os.Getpid())) if err != nil { log.Debugf("Failed to create process instance: %v", err) return nil } memInfo, err := proc.MemoryInfo() if err != nil { log.Debugf("Failed to get process memory info: %v", err) return nil } cpuPercent, err := proc.CPUPercent() if err != nil { log.Debugf("Failed to get process CPU percent: %v", err) return nil } return &system.Info{ MemoryUsed: memInfo.RSS, CPUPercent: cpuPercent, StartTime: startTime, UploadBytes: atomic.LoadUint64(&uploadBytes), DownloadBytes: atomic.LoadUint64(&downloadBytes), } } func Reset() { atomic.StoreUint64(&uploadBytes, 0) atomic.StoreUint64(&downloadBytes, 0) } ================================================ FILE: internal/core/task/task.go ================================================ package task import "github.com/panjf2000/ants/v2" var pool *ants.Pool var thread int func Init(maxThread int) { pool, _ = ants.NewPool(maxThread) thread = maxThread } func Submit(fn func()) { pool.Submit(fn) } func Release() { pool.Release() } func MaxThread() int { return thread } ================================================ FILE: internal/core/update/core.go ================================================ package update import ( "fmt" "os" "os/exec" "path/filepath" "runtime" "syscall" "github.com/bestruirui/bestsub/internal/database/op" "github.com/bestruirui/bestsub/internal/models/setting" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/bestruirui/bestsub/internal/utils/shutdown" ) func UpdateCore() error { log.Infof("start update core") err := updateCore() if err != nil { log.Warnf("update core failed, please update manually", err) return err } log.Infof("update core success") return nil } func updateCore() error { arch := runtime.GOARCH goos := runtime.GOOS var downloadUrl string var filename string switch goos { case "windows": switch arch { case "386": filename = "bestsub-windows-x86.zip" case "amd64": filename = "bestsub-windows-x86_64.zip" default: log.Errorf("unsupported windows architecture: %s", arch) return fmt.Errorf("unsupported windows architecture: %s", arch) } case "darwin": switch arch { case "amd64": filename = "bestsub-darwin-x86_64.zip" case "arm64": filename = "bestsub-darwin-arm64.zip" default: log.Errorf("unsupported darwin architecture: %s", arch) return fmt.Errorf("unsupported darwin architecture: %s", arch) } case "linux": switch arch { case "386": filename = "bestsub-linux-x86.zip" case "amd64": filename = "bestsub-linux-x86_64.zip" case "arm": filename = "bestsub-linux-armv7.zip" case "arm64": filename = "bestsub-linux-arm64.zip" default: log.Errorf("unsupported linux architecture: %s", arch) return fmt.Errorf("unsupported linux architecture: %s", arch) } default: log.Errorf("unsupported operating system: %s", goos) return fmt.Errorf("unsupported operating system: %s", goos) } downloadUrl = bestsubUpdateUrl + "/" + filename bytes, err := download(downloadUrl, op.GetSettingBool(setting.PROXY_ENABLE)) if err != nil { return err } execPath, err := os.Executable() if err != nil { return err } execDir := filepath.Dir(execPath) if err := unzip(bytes, execDir); err != nil { return err } go restartExecutable(execPath) return nil } func restartExecutable(execPath string) { var err error shutdown.All() if runtime.GOOS == "windows" { cmd := exec.Command(execPath, os.Args[1:]...) log.Infof("restarting: %q %q", execPath, os.Args[1:]) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err = cmd.Start() if err != nil { log.Errorf("restarting: %s", err) } os.Exit(0) } log.Infof("restarting: %q %q", execPath, os.Args[1:]) err = syscall.Exec(execPath, os.Args, os.Environ()) if err != nil { log.Errorf("restarting: %s", err) } log.Infof("restarting success") } ================================================ FILE: internal/core/update/update.go ================================================ package update import ( "archive/zip" "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "time" "github.com/bestruirui/bestsub/internal/core/mihomo" "github.com/bestruirui/bestsub/internal/database/op" "github.com/bestruirui/bestsub/internal/models/setting" "github.com/bestruirui/bestsub/internal/utils/log" ) const ( bestsubUpdateUrl = "https://github.com/bestruirui/bestsub/releases/latest/download" bestsubUpdateApiUrl = "https://api.github.com/repos/bestruirui/BestSub/releases/latest" ) type LatestInfo struct { TagName string `json:"tag_name"` PublishedAt string `json:"published_at"` Body string `json:"body"` Message string `json:"message"` } func GetLatestBestsubInfo() (*LatestInfo, error) { return getLatestInfo(bestsubUpdateApiUrl, op.GetSettingBool(setting.PROXY_ENABLE)) } func getLatestInfo(url string, proxy bool) (*LatestInfo, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() hc := mihomo.Default(proxy) if hc == nil { return nil, fmt.Errorf("failed to create http client") } defer hc.Release() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { log.Debugf("new request failed: %v", err) return nil, err } resp, err := hc.Do(req) if err != nil { log.Debugf("request failed: %v", err) return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { log.Debugf("read body failed: %v", err) return nil, err } latestInfo := LatestInfo{} err = json.Unmarshal(body, &latestInfo) if err != nil { log.Debugf("unmarshal body failed: %v", err) return nil, err } if latestInfo.Message != "" { return nil, fmt.Errorf("failed to get latest info: %s", latestInfo.Message) } return &latestInfo, nil } func download(url string, proxy bool) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() hc := mihomo.Default(proxy) if hc == nil { return nil, fmt.Errorf("failed to create http client") } defer hc.Release() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { log.Debugf("new request failed: %v", err) return nil, err } resp, err := hc.Do(req) if err != nil { log.Debugf("request failed: %v", err) return nil, err } defer resp.Body.Close() bytes, err := io.ReadAll(resp.Body) if err != nil { log.Debugf("read body failed: %v", err) return nil, err } return bytes, nil } func unzip(data []byte, dest string) error { r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { log.Debugf("new zip reader failed: %v", err) return err } for _, f := range r.File { fpath := filepath.Join(dest, f.Name) if !inDest(fpath, dest) { log.Debugf("invalid file path: %s", fpath) return fmt.Errorf("invalid file path: %s", fpath) } info := f.FileInfo() if info.IsDir() { os.MkdirAll(fpath, os.ModePerm) continue } if info.Mode()&os.ModeSymlink != 0 { continue } if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { log.Debugf("mkdir all failed: %v", err) return err } outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode().Perm()) if err != nil { err = os.Remove(fpath) if err != nil { log.Debugf("remove file failed: %v", err) return err } outFile, err = os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { log.Debugf("open file failed: %v", err) return err } } defer outFile.Close() rc, err := f.Open() if err != nil { log.Debugf("open file failed: %v", err) return err } _, err = io.Copy(outFile, rc) rc.Close() if err != nil { log.Debugf("copy failed: %v", err) return err } } return nil } func inDest(fpath, dest string) bool { if rel, err := filepath.Rel(dest, fpath); err == nil { if filepath.IsLocal(rel) { return true } } return false } ================================================ FILE: internal/database/client/sqlite/auth.go ================================================ package sqlite import ( "context" "database/sql" "fmt" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/models/auth" "github.com/bestruirui/bestsub/internal/utils/log" ) // Get 获取认证信息 func (db *DB) Auth() interfaces.AuthRepository { return &AuthRepository{db: db} } // AuthRepository 认证数据访问实现 type AuthRepository struct { db *DB } // Get 获取认证信息 func (db *AuthRepository) Get(ctx context.Context) (*auth.Data, error) { log.Debugf("Get auth") query := `SELECT id, username, password FROM auth LIMIT 1` var authData auth.Data err := db.db.db.QueryRowContext(ctx, query).Scan( &authData.ID, &authData.UserName, &authData.Password, ) if err != nil { if err == sql.ErrNoRows { return nil, nil } return nil, fmt.Errorf("failed to get auth: %w", err) } return &authData, nil } // UpdateName 更新用户名 func (r *AuthRepository) UpdateName(ctx context.Context, name string) error { log.Debugf("UpdateName: %s", name) query := `UPDATE auth SET username = ? WHERE id = (SELECT id FROM auth LIMIT 1)` result, err := r.db.db.ExecContext(ctx, query, name) if err != nil { return fmt.Errorf("failed to update username: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rowsAffected == 0 { return fmt.Errorf("no auth record found to update") } return nil } // UpdatePassword 更新密码 func (r *AuthRepository) UpdatePassword(ctx context.Context, hashPassword string) error { log.Debugf("UpdatePassword: %s", hashPassword) query := `UPDATE auth SET password = ? WHERE id = (SELECT id FROM auth LIMIT 1)` result, err := r.db.db.ExecContext(ctx, query, hashPassword) if err != nil { return fmt.Errorf("failed to update password: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rowsAffected == 0 { return fmt.Errorf("no auth record found to update") } return nil } // Initialize 初始化认证信息 func (r *AuthRepository) Initialize(ctx context.Context, authData *auth.Data) error { log.Debugf("Initialize: %s", authData.UserName) query := `INSERT INTO auth (username, password) VALUES (?, ?)` _, err := r.db.db.ExecContext(ctx, query, authData.UserName, authData.Password) if err != nil { return fmt.Errorf("failed to initialize auth: %w", err) } return nil } // IsInitialized 验证是否已初始化 func (r *AuthRepository) IsInitialized(ctx context.Context) (bool, error) { log.Debugf("IsInitialized") query := `SELECT EXISTS(SELECT 1 FROM auth LIMIT 1)` var exists bool err := r.db.db.QueryRowContext(ctx, query).Scan(&exists) if err != nil { return false, fmt.Errorf("failed to check auth initialization: %w", err) } return exists, nil } ================================================ FILE: internal/database/client/sqlite/check.go ================================================ package sqlite import ( "context" "database/sql" "fmt" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/models/check" "github.com/bestruirui/bestsub/internal/utils/log" ) type CheckRepository struct { db *DB } func (db *DB) Check() interfaces.CheckRepository { return &CheckRepository{db: db} } func (r *CheckRepository) Create(ctx context.Context, t *check.Data) error { log.Debugf("Create check") query := `INSERT INTO check_task (enable, name, task, config, result) VALUES (?, ?, ?, ?, ?)` result, err := r.db.db.ExecContext(ctx, query, t.Enable, t.Name, t.Task, t.Config, t.Result, ) if err != nil { return fmt.Errorf("failed to create check: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get check id: %w", err) } t.ID = uint16(id) return nil } func (r *CheckRepository) GetByID(ctx context.Context, id uint16) (*check.Data, error) { log.Debugf("Get check by id") query := `SELECT id, enable, name, task, config, result FROM check_task WHERE id = ?` var t check.Data err := r.db.db.QueryRowContext(ctx, query, id).Scan( &t.ID, &t.Enable, &t.Name, &t.Task, &t.Config, &t.Result, ) if err != nil { if err == sql.ErrNoRows { return nil, nil } return nil, fmt.Errorf("failed to get check by id: %w", err) } return &t, nil } func (r *CheckRepository) Update(ctx context.Context, t *check.Data) error { log.Debugf("Update check") query := `UPDATE check_task SET enable = ?, name = ?, task = ?, config = ?, result = ? WHERE id = ?` _, err := r.db.db.ExecContext(ctx, query, t.Enable, t.Name, t.Task, t.Config, t.Result, t.ID, ) if err != nil { return fmt.Errorf("failed to update check: %w", err) } return nil } func (r *CheckRepository) Delete(ctx context.Context, id uint16) error { log.Debugf("Delete check") query := `DELETE FROM check_task WHERE id = ?` _, err := r.db.db.ExecContext(ctx, query, id) if err != nil { return fmt.Errorf("failed to delete check: %w", err) } return nil } func (r *CheckRepository) List(ctx context.Context) (*[]check.Data, error) { log.Debugf("List check") query := `SELECT id, enable, name, task, config, result FROM check_task ORDER BY id DESC` rows, err := r.db.db.QueryContext(ctx, query) if err != nil { log.Errorf("failed to list checks: %v", err) return nil, fmt.Errorf("failed to list checks: %w", err) } defer rows.Close() var checks []check.Data for rows.Next() { var t check.Data err := rows.Scan( &t.ID, &t.Enable, &t.Name, &t.Task, &t.Config, &t.Result, ) if err != nil { log.Errorf("failed to scan check: %v", err) return nil, fmt.Errorf("failed to scan check: %w", err) } checks = append(checks, t) } if err = rows.Err(); err != nil { log.Errorf("failed to iterate checks: %v", err) return nil, fmt.Errorf("failed to iterate checks: %w", err) } return &checks, nil } ================================================ FILE: internal/database/client/sqlite/migration/001_table.go ================================================ package migration import "github.com/bestruirui/bestsub/internal/database/migration" // Migration001Table 初始数据库架构 func Migration001Table() string { return ` CREATE TABLE IF NOT EXISTS "auth" ( "id" INTEGER, "username" TEXT NOT NULL UNIQUE, "password" TEXT NOT NULL, PRIMARY KEY("id") ); CREATE TABLE IF NOT EXISTS "setting" ( "key" TEXT NOT NULL UNIQUE, "value" TEXT NOT NULL, PRIMARY KEY("key") ); CREATE TABLE IF NOT EXISTS "notify_template" ( "type" TEXT NOT NULL, "template" TEXT NOT NULL, PRIMARY KEY("type") ); CREATE TABLE IF NOT EXISTS "notify" ( "id" INTEGER NOT NULL UNIQUE, "name" TEXT NOT NULL, "type" TEXT NOT NULL, "config" TEXT NOT NULL, PRIMARY KEY("id") ); CREATE TABLE IF NOT EXISTS "check_task" ( "id" INTEGER, "enable" BOOLEAN NOT NULL, "name" TEXT, "task" TEXT NOT NULL, "config" TEXT, "result" TEXT, PRIMARY KEY("id") ); CREATE TABLE IF NOT EXISTS "storage" ( "id" INTEGER, "name" TEXT, "type" TEXT NOT NULL, "config" TEXT NOT NULL, PRIMARY KEY("id") ); CREATE TABLE IF NOT EXISTS "sub_template" ( "id" INTEGER, "name" TEXT, "type" TEXT NOT NULL, "template" TEXT NOT NULL, PRIMARY KEY("id") ); CREATE TABLE IF NOT EXISTS "sub" ( "id" INTEGER NOT NULL, "enable" BOOLEAN NOT NULL DEFAULT true, "name" TEXT, "cron_expr" TEXT NOT NULL, "config" TEXT NOT NULL, "result" TEXT DEFAULT '{}', "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY("id") ); CREATE TABLE IF NOT EXISTS "share" ( "id" INTEGER NOT NULL, "enable" BOOLEAN NOT NULL DEFAULT false, "name" TEXT NOT NULL, "gen" TEXT NOT NULL, "token" TEXT NOT NULL UNIQUE, "access_count" INTEGER DEFAULT 0, "max_access_count" INTEGER DEFAULT 0, "expires" INTEGER DEFAULT 0, PRIMARY KEY("id") ); CREATE TABLE IF NOT EXISTS "migration" ( "date" INTEGER NOT NULL UNIQUE, "version" TEXT NOT NULL, "description" TEXT, "applied_at" DATETIME NOT NULL, PRIMARY KEY("date") ); ` } // init 自动注册迁移 func init() { migration.Register(ClientName, 202507171100, "dev", "Tables", Migration001Table) } ================================================ FILE: internal/database/client/sqlite/migration/002_add_sub_tags.go ================================================ package migration import "github.com/bestruirui/bestsub/internal/database/migration" // Migration002AddSubTags 为sub表添加列tags func Migration002AddSubTags() string { return ` ALTER TABLE "sub" ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'; ` } // init 自动注册迁移 func init() { migration.Register(ClientName, 202511082145, "dev", "Add Sub Tags", Migration002AddSubTags) } ================================================ FILE: internal/database/client/sqlite/migration/migration.go ================================================ package migration import "github.com/bestruirui/bestsub/internal/database/migration" const ClientName = "sqlite" func Get() []*migration.Info { return migration.Get(ClientName) } ================================================ FILE: internal/database/client/sqlite/migrator.go ================================================ package sqlite import ( "fmt" "time" "github.com/bestruirui/bestsub/internal/database/client/sqlite/migration" migModel "github.com/bestruirui/bestsub/internal/database/migration" ) func (db *DB) Migrate() error { migrations := migration.Get() if len(migrations) == 0 { return nil } if err := db.ensureMigrationsTable(); err != nil { return fmt.Errorf("failed to ensure migrations table: %w", err) } appliedDates, err := db.getAppliedMigrations() if err != nil { return fmt.Errorf("failed to get applied migrations: %w", err) } var pendingMigrations []*migModel.Info for _, migration := range migrations { if !appliedDates[migration.Date] { pendingMigrations = append(pendingMigrations, migration) } } if len(pendingMigrations) == 0 { return nil } return db.applyMigrations(pendingMigrations) } func (db *DB) ensureMigrationsTable() error { migrationTable := ` CREATE TABLE IF NOT EXISTS "migration" ( "date" INTEGER NOT NULL UNIQUE, "version" TEXT NOT NULL, "description" TEXT NOT NULL, "applied_at" DATETIME NOT NULL, PRIMARY KEY("date") );` _, err := db.db.Exec(migrationTable) if err != nil { return fmt.Errorf("failed to create migration table: %w", err) } return nil } func (db *DB) getAppliedMigrations() (map[uint64]bool, error) { appliedDates := make(map[uint64]bool) rows, err := db.db.Query("SELECT date FROM migration") if err != nil { return appliedDates, err } defer rows.Close() for rows.Next() { var date uint64 if err := rows.Scan(&date); err != nil { return nil, fmt.Errorf("failed to scan migration date: %w", err) } appliedDates[date] = true } if err := rows.Err(); err != nil { return nil, fmt.Errorf("failed to iterate migration rows: %w", err) } return appliedDates, nil } func (db *DB) applyMigrations(migrations []*migModel.Info) error { tx, err := db.db.Begin() if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() insertStmt, err := tx.Prepare("INSERT INTO migration (date, version, description, applied_at) VALUES (?, ?, ?, ?)") if err != nil { return fmt.Errorf("failed to prepare insert statement: %w", err) } defer insertStmt.Close() for _, migration := range migrations { _, err = tx.Exec(migration.Content()) if err != nil { return fmt.Errorf("failed to execute migration %d SQL: %w", migration.Date, err) } _, err = insertStmt.Exec(migration.Date, migration.Version, migration.Description, time.Now()) if err != nil { return fmt.Errorf("failed to record migration %d: %w", migration.Date, err) } } if err = tx.Commit(); err != nil { return fmt.Errorf("failed to commit migrations transaction: %w", err) } return nil } ================================================ FILE: internal/database/client/sqlite/notify.go ================================================ package sqlite import ( "context" "database/sql" "fmt" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/models/notify" "github.com/bestruirui/bestsub/internal/utils/log" ) type NotifyRepository struct { db *DB } func (db *DB) Notify() interfaces.NotifyRepository { return &NotifyRepository{db: db} } func (r *NotifyRepository) Create(ctx context.Context, channel *notify.Data) error { log.Debugf("Create notify") query := `INSERT INTO notify (name, type, config ) VALUES (?, ?, ?)` result, err := r.db.db.ExecContext(ctx, query, channel.Name, channel.Type, channel.Config, ) if err != nil { return fmt.Errorf("failed to create notification channel: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get notification channel id: %w", err) } channel.ID = uint16(id) return nil } func (r *NotifyRepository) GetByID(ctx context.Context, id uint16) (*notify.Data, error) { log.Debugf("Get notify by id") query := `SELECT id, name, type, config FROM notify WHERE id = ?` var channel notify.Data err := r.db.db.QueryRowContext(ctx, query, id).Scan( &channel.ID, &channel.Name, &channel.Type, &channel.Config, ) if err != nil { if err == sql.ErrNoRows { return nil, nil } return nil, fmt.Errorf("failed to get notification channel by id: %w", err) } return &channel, nil } func (r *NotifyRepository) Update(ctx context.Context, channel *notify.Data) error { log.Debugf("Update notify") query := `UPDATE notify SET name = ?, type = ?, config = ? WHERE id = ?` _, err := r.db.db.ExecContext(ctx, query, channel.Name, channel.Type, channel.Config, channel.ID, ) if err != nil { return fmt.Errorf("failed to update notification channel: %w", err) } return nil } func (r *NotifyRepository) Delete(ctx context.Context, id uint16) error { log.Debugf("Delete notify") query := `DELETE FROM notify WHERE id = ?` _, err := r.db.db.ExecContext(ctx, query, id) if err != nil { return fmt.Errorf("failed to delete notification channel: %w", err) } return nil } func (r *NotifyRepository) List(ctx context.Context) (*[]notify.Data, error) { log.Debugf("List notify") query := `SELECT id, name, type, config FROM notify ORDER BY id DESC` rows, err := r.db.db.QueryContext(ctx, query) if err != nil { return nil, fmt.Errorf("failed to list notification channels: %w", err) } defer rows.Close() var channels []notify.Data for rows.Next() { var channel notify.Data err := rows.Scan( &channel.ID, &channel.Name, &channel.Type, &channel.Config, ) if err != nil { return nil, fmt.Errorf("failed to scan notification channel: %w", err) } channels = append(channels, channel) } if err = rows.Err(); err != nil { return nil, fmt.Errorf("failed to iterate notification channels: %w", err) } return &channels, nil } type NotifyTemplateRepository struct { db *DB } func (db *DB) NotifyTemplate() interfaces.NotifyTemplateRepository { return &NotifyTemplateRepository{db: db} } func (r *NotifyTemplateRepository) Create(ctx context.Context, template *notify.Template) error { log.Debugf("Create notify template") query := `INSERT INTO notify_template (type, template) VALUES (?, ?)` _, err := r.db.db.ExecContext(ctx, query, template.Type, template.Template, ) if err != nil { return fmt.Errorf("failed to create notify template: %w", err) } return nil } func (r *NotifyTemplateRepository) GetByType(ctx context.Context, t string) (*notify.Template, error) { log.Debugf("Get notify template by type") query := `SELECT type, template FROM notify_template WHERE type = ?` var template notify.Template err := r.db.db.QueryRowContext(ctx, query, t).Scan( &template.Type, &template.Template, ) if err != nil { if err == sql.ErrNoRows { return nil, nil } return nil, fmt.Errorf("failed to get notify template by type: %w", err) } return &template, nil } func (r *NotifyTemplateRepository) Update(ctx context.Context, template *notify.Template) error { log.Debugf("Update Notify Template") query := `UPDATE notify_template SET template = ? WHERE type = ?` _, err := r.db.db.ExecContext(ctx, query, template.Template, template.Type, ) if err != nil { return fmt.Errorf("failed to update notify template: %w", err) } return nil } func (r *NotifyTemplateRepository) List(ctx context.Context) (*[]notify.Template, error) { log.Debugf("List notify template") query := `SELECT type, template FROM notify_template ORDER BY type DESC` rows, err := r.db.db.QueryContext(ctx, query) if err != nil { return nil, fmt.Errorf("failed to list notify templates: %w", err) } defer rows.Close() var templates []notify.Template for rows.Next() { var template notify.Template err := rows.Scan( &template.Type, &template.Template, ) if err != nil { return nil, fmt.Errorf("failed to scan notify template: %w", err) } templates = append(templates, template) } if err = rows.Err(); err != nil { return nil, fmt.Errorf("failed to iterate notify templates: %w", err) } return &templates, nil } ================================================ FILE: internal/database/client/sqlite/setting.go ================================================ package sqlite import ( "context" "fmt" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/models/setting" "github.com/bestruirui/bestsub/internal/utils/log" ) func (db *DB) Setting() interfaces.SettingRepository { return &SystemConfigRepository{db: db} } type SystemConfigRepository struct { db *DB } func (r *SystemConfigRepository) Create(ctx context.Context, configs *[]setting.Setting) error { if configs == nil || len(*configs) == 0 { return nil } tx, err := r.db.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() query := `INSERT INTO setting (key, value) VALUES (?, ?)` stmt, err := tx.PrepareContext(ctx, query) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) } defer stmt.Close() for _, config := range *configs { _, err := stmt.ExecContext(ctx, config.Key, config.Value, ) if err != nil { return fmt.Errorf("failed to create system config key '%s': %w", config.Key, err) } } if err = tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil } func (r *SystemConfigRepository) GetAll(ctx context.Context) (*[]setting.Setting, error) { log.Debugf("GetAll") query := `SELECT key, value FROM setting ORDER BY key` rows, err := r.db.db.QueryContext(ctx, query) if err != nil { return nil, fmt.Errorf("failed to query all configs: %w", err) } defer rows.Close() var configs []setting.Setting for rows.Next() { var config setting.Setting if err := rows.Scan( &config.Key, &config.Value, ); err != nil { return nil, fmt.Errorf("failed to scan config: %w", err) } configs = append(configs, config) } if err = rows.Err(); err != nil { return nil, fmt.Errorf("failed to iterate configs: %w", err) } return &configs, nil } func (r *SystemConfigRepository) GetByKey(ctx context.Context, keys []string) (*[]setting.Setting, error) { log.Debugf("GetByKey: %v", keys) if len(keys) == 0 { return &[]setting.Setting{}, nil } args := make([]interface{}, len(keys)) inClause := "" for i, key := range keys { if i > 0 { inClause += "," } inClause += "?" args[i] = key } query := `SELECT key, value FROM setting WHERE key IN (` + inClause + `) ORDER BY key` rows, err := r.db.db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("failed to query configs by keys: %w", err) } defer rows.Close() var configs []setting.Setting for rows.Next() { var config setting.Setting if err := rows.Scan( &config.Key, &config.Value, ); err != nil { return nil, fmt.Errorf("failed to scan config: %w", err) } configs = append(configs, config) } if err = rows.Err(); err != nil { return nil, fmt.Errorf("failed to iterate configs: %w", err) } return &configs, nil } func (r *SystemConfigRepository) Update(ctx context.Context, data *[]setting.Setting) error { log.Debugf("Update: %v", data) if data == nil || len(*data) == 0 { return nil } tx, err := r.db.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() query := `UPDATE setting SET value = ? WHERE key = ?` stmt, err := tx.PrepareContext(ctx, query) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) } defer stmt.Close() for _, updateData := range *data { result, err := stmt.ExecContext(ctx, updateData.Value, updateData.Key, ) if err != nil { return fmt.Errorf("failed to update system config key '%s': %w", updateData.Key, err) } rowsAffected, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected for key '%s': %w", updateData.Key, err) } if rowsAffected == 0 { return fmt.Errorf("no config found with key '%s'", updateData.Key) } } if err = tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil } ================================================ FILE: internal/database/client/sqlite/share.go ================================================ package sqlite import ( "context" "database/sql" "fmt" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/models/share" "github.com/bestruirui/bestsub/internal/utils/log" ) type ShareRepository struct { db *DB } func (db *DB) Share() interfaces.ShareRepository { return &ShareRepository{db: db} } func (r *ShareRepository) Create(ctx context.Context, shareData *share.Data) error { log.Debugf("Create share") query := `INSERT INTO share (enable, name, token, gen, max_access_count, expires) VALUES (?, ?, ?, ?, ?, ?)` result, err := r.db.db.ExecContext(ctx, query, shareData.Enable, shareData.Name, shareData.Token, shareData.Gen, shareData.MaxAccessCount, shareData.Expires, ) if err != nil { return fmt.Errorf("failed to create share link: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get share link id: %w", err) } shareData.ID = uint16(id) return nil } func (r *ShareRepository) GetByID(ctx context.Context, id uint16) (*share.Data, error) { log.Debugf("Get share by id") query := `SELECT id, enable, name, token, gen, access_count, expires, max_access_count FROM share WHERE id = ?` var shareData share.Data err := r.db.db.QueryRowContext(ctx, query, id).Scan( &shareData.ID, &shareData.Enable, &shareData.Name, &shareData.Token, &shareData.Gen, &shareData.AccessCount, &shareData.Expires, &shareData.MaxAccessCount, ) if err != nil { if err == sql.ErrNoRows { return nil, nil } return nil, fmt.Errorf("failed to get share link by id: %w", err) } return &shareData, nil } func (r *ShareRepository) Update(ctx context.Context, shareData *share.Data) error { log.Debugf("Update share") query := `UPDATE share SET enable = ?, name = ?, token = ?, gen = ?, access_count = ?, max_access_count = ?, expires = ? WHERE id = ?` _, err := r.db.db.ExecContext(ctx, query, shareData.Enable, shareData.Name, shareData.Token, shareData.Gen, shareData.AccessCount, shareData.MaxAccessCount, shareData.Expires, shareData.ID, ) if err != nil { return fmt.Errorf("failed to update share link: %w", err) } return nil } func (r *ShareRepository) UpdateAccessCount(ctx context.Context, shareLinks *[]share.UpdateAccessCountDB) error { if shareLinks == nil || len(*shareLinks) == 0 { return nil } log.Debugf("Batch update share access count for %d items", len(*shareLinks)) tx, err := r.db.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() query := `UPDATE share SET access_count = ? WHERE id = ?` stmt, err := tx.PrepareContext(ctx, query) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) } defer stmt.Close() for _, shareLink := range *shareLinks { _, err := stmt.ExecContext(ctx, shareLink.AccessCount, shareLink.ID) if err != nil { return fmt.Errorf("failed to update share access count for id %d: %w", shareLink.ID, err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil } func (r *ShareRepository) Delete(ctx context.Context, id uint16) error { log.Debugf("Delete share") query := `DELETE FROM share WHERE id = ?` _, err := r.db.db.ExecContext(ctx, query, id) if err != nil { return fmt.Errorf("failed to delete share link: %w", err) } return nil } func (r *ShareRepository) List(ctx context.Context) (*[]share.Data, error) { log.Debugf("List share") query := `SELECT id, enable, name, token, gen, access_count, expires, max_access_count FROM share` rows, err := r.db.db.QueryContext(ctx, query) if err != nil { return nil, fmt.Errorf("failed to list share links: %w", err) } defer rows.Close() var shareDatas []share.Data for rows.Next() { var shareData share.Data err := rows.Scan( &shareData.ID, &shareData.Enable, &shareData.Name, &shareData.Token, &shareData.Gen, &shareData.AccessCount, &shareData.Expires, &shareData.MaxAccessCount, ) if err != nil { return nil, fmt.Errorf("failed to scan share link: %w", err) } shareDatas = append(shareDatas, shareData) } if err = rows.Err(); err != nil { return nil, fmt.Errorf("failed to iterate share links: %w", err) } return &shareDatas, nil } func (r *ShareRepository) GetGenByToken(ctx context.Context, token string) (string, error) { log.Debugf("Get config by token") query := `SELECT gen FROM share WHERE token = ?` var config string err := r.db.db.QueryRowContext(ctx, query, token).Scan(&config) if err != nil { return "", fmt.Errorf("failed to get config by token: %w", err) } return config, nil } ================================================ FILE: internal/database/client/sqlite/sqlite.go ================================================ package sqlite import ( "database/sql" "fmt" "time" "github.com/bestruirui/bestsub/internal/database/interfaces" _ "modernc.org/sqlite" ) // DB SQLite数据库连接包装器 type DB struct { db *sql.DB } // New 创建新的SQLite数据库连接 func New(databasePath string) (interfaces.Repository, error) { db, err := sql.Open("sqlite", databasePath) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) db.SetConnMaxLifetime(time.Hour) if err := enablePragmas(db); err != nil { db.Close() return nil, fmt.Errorf("failed to set pragmas: %w", err) } repository := DB{db: db} return &repository, nil } // Close 关闭数据库连接 func (db *DB) Close() error { return db.db.Close() } // enablePragmas 启用SQLite优化选项 func enablePragmas(db *sql.DB) error { pragmas := map[string]string{ "journal_mode": "WAL", // 启用WAL模式提高并发性能 "synchronous": "NORMAL", // 平衡性能和安全性 "cache_size": "-64000", // 64MB缓存 "foreign_keys": "ON", // 启用外键约束 "temp_store": "MEMORY", // 临时表存储在内存中 "busy_timeout": "5000", // 5秒忙等待超时 "wal_autocheckpoint": "1000", // WAL自动检查点 "optimize": "", // 优化数据库 } for key, value := range pragmas { var query string if value == "" { query = fmt.Sprintf("PRAGMA %s", key) } else { query = fmt.Sprintf("PRAGMA %s = %s", key, value) } if _, err := db.Exec(query); err != nil { return fmt.Errorf("failed to execute pragma %s: %w", query, err) } } return nil } ================================================ FILE: internal/database/client/sqlite/storage.go ================================================ package sqlite import ( "context" "database/sql" "fmt" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/models/storage" "github.com/bestruirui/bestsub/internal/utils/log" ) // StorageRepository 存储配置数据访问实现 type StorageRepository struct { db *DB } // newStorageRepository 创建存储配置仓库 func (db *DB) Storage() interfaces.StorageRepository { return &StorageRepository{db: db} } // Create 创建存储配置 func (r *StorageRepository) Create(ctx context.Context, config *storage.Data) error { log.Debugf("Create storage config") query := `INSERT INTO storage (name, type, config) VALUES (?, ?, ?)` result, err := r.db.db.ExecContext(ctx, query, config.Name, config.Type, config.Config, ) if err != nil { return fmt.Errorf("failed to create storage config: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get storage config id: %w", err) } config.ID = uint16(id) return nil } // GetByID 根据ID获取存储配置 func (r *StorageRepository) GetByID(ctx context.Context, id uint16) (*storage.Data, error) { log.Debugf("Get storage config by id") query := `SELECT id, name, type, config FROM storage WHERE id = ?` var config storage.Data err := r.db.db.QueryRowContext(ctx, query, id).Scan( &config.ID, &config.Name, &config.Type, &config.Config, ) if err != nil { if err == sql.ErrNoRows { return nil, nil } return nil, fmt.Errorf("failed to get storage config by id: %w", err) } return &config, nil } // Update 更新存储配置 func (r *StorageRepository) Update(ctx context.Context, config *storage.Data) error { log.Debugf("Update storage config") query := `UPDATE storage SET name = ?, type = ?, config = ? WHERE id = ?` _, err := r.db.db.ExecContext(ctx, query, config.Name, config.Type, config.Config, config.ID, ) if err != nil { return fmt.Errorf("failed to update storage config: %w", err) } return nil } // Delete 删除存储配置 func (r *StorageRepository) Delete(ctx context.Context, id uint16) error { log.Debugf("Delete storage config") query := `DELETE FROM storage WHERE id = ?` _, err := r.db.db.ExecContext(ctx, query, id) if err != nil { return fmt.Errorf("failed to delete storage config: %w", err) } return nil } // List 获取存储配置列表 func (r *StorageRepository) List(ctx context.Context) (*[]storage.Data, error) { log.Debugf("List storage configs") query := `SELECT id, name, type, config FROM storage` var configs []storage.Data rows, err := r.db.db.QueryContext(ctx, query) if err != nil { return nil, fmt.Errorf("failed to list storage configs: %w", err) } defer rows.Close() for rows.Next() { var config storage.Data err := rows.Scan( &config.ID, &config.Name, &config.Type, &config.Config, ) if err != nil { return nil, fmt.Errorf("failed to scan storage config: %w", err) } configs = append(configs, config) } return &configs, nil } ================================================ FILE: internal/database/client/sqlite/sub.go ================================================ package sqlite import ( "context" "database/sql" "fmt" "time" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/models/sub" "github.com/bestruirui/bestsub/internal/utils/log" ) type SubRepository struct { db *DB } func (db *DB) Sub() interfaces.SubRepository { return &SubRepository{db: db} } func (r *SubRepository) Create(ctx context.Context, link *sub.Data) error { log.Debugf("Create sub") query := `INSERT INTO sub (enable, name, tags, cron_expr, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)` now := time.Now() result, err := r.db.db.ExecContext(ctx, query, link.Enable, link.Name, link.Tags, link.CronExpr, link.Config, now, now, ) if err != nil { return fmt.Errorf("failed to create sub: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get sub id: %w", err) } link.ID = uint16(id) link.CreatedAt = now link.UpdatedAt = now return nil } func (r *SubRepository) GetByID(ctx context.Context, id uint16) (*sub.Data, error) { log.Debugf("Get sub by id") query := `SELECT id, enable, name, tags, cron_expr, config, result, created_at, updated_at FROM sub WHERE id = ?` var s sub.Data err := r.db.db.QueryRowContext(ctx, query, id).Scan( &s.ID, &s.Enable, &s.Name, &s.Tags, &s.CronExpr, &s.Config, &s.Result, &s.CreatedAt, &s.UpdatedAt, ) if err != nil { if err == sql.ErrNoRows { return nil, nil } return nil, fmt.Errorf("failed to get sub by id: %w", err) } return &s, nil } func (r *SubRepository) Update(ctx context.Context, data *sub.Data) error { log.Debugf("Update sub") query := `UPDATE sub SET enable = ?, name = ?, tags = ?, cron_expr = ?, config = ?, result = ?, updated_at = ? WHERE id = ?` data.UpdatedAt = time.Now() _, err := r.db.db.ExecContext(ctx, query, data.Enable, data.Name, data.Tags, data.CronExpr, data.Config, data.Result, data.UpdatedAt, data.ID, ) if err != nil { return fmt.Errorf("failed to update sub: %w", err) } return nil } func (r *SubRepository) Delete(ctx context.Context, id uint16) error { log.Debugf("Delete sub") query := `DELETE FROM sub WHERE id = ?` _, err := r.db.db.ExecContext(ctx, query, id) if err != nil { return fmt.Errorf("failed to delete sub: %w", err) } return nil } func (r *SubRepository) List(ctx context.Context) (*[]sub.Data, error) { log.Debugf("List sub") query := `SELECT id, enable, name, tags, cron_expr, config, result, created_at, updated_at FROM sub ORDER BY id DESC` rows, err := r.db.db.QueryContext(ctx, query) if err != nil { return nil, fmt.Errorf("failed to list sub: %w", err) } defer rows.Close() var subs []sub.Data for rows.Next() { var s sub.Data err := rows.Scan( &s.ID, &s.Enable, &s.Name, &s.Tags, &s.CronExpr, &s.Config, &s.Result, &s.CreatedAt, &s.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to scan sub: %w", err) } subs = append(subs, s) } if err = rows.Err(); err != nil { return nil, fmt.Errorf("failed to iterate subs: %w", err) } return &subs, nil } func (r *SubRepository) BatchCreate(ctx context.Context, links []*sub.Data) error { log.Debugf("Batch create %d subs", len(links)) if len(links) == 0 { return nil } tx, err := r.db.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if err != nil { tx.Rollback() } }() query := `INSERT INTO sub (enable, name, tags, cron_expr, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)` now := time.Now() stmt, err := tx.PrepareContext(ctx, query) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) } defer stmt.Close() for _, link := range links { result, err := stmt.ExecContext(ctx, link.Enable, link.Name, link.Tags, link.CronExpr, link.Config, now, now, ) if err != nil { return fmt.Errorf("failed to execute batch insert: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get sub id: %w", err) } link.ID = uint16(id) link.CreatedAt = now link.UpdatedAt = now } if err = tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil } ================================================ FILE: internal/database/database.go ================================================ package database import ( "context" "github.com/bestruirui/bestsub/internal/database/client/sqlite" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/database/op" "github.com/bestruirui/bestsub/internal/utils/log" ) func Initialize(sqltype, path string) error { var err error var repo interfaces.Repository switch sqltype { case "sqlite": repo, err = sqlite.New(path) if err != nil { log.Fatalf("failed to create sqlite database: %v", err) } default: log.Fatalf("unsupported database type: %s", sqltype) } op.SetRepo(repo) if err := repo.Migrate(); err != nil { log.Fatalf("failed to migrate database: %v", err) } if err := initAuth(context.Background(), op.AuthRepo()); err != nil { log.Fatalf("failed to initialize auth: %v", err) } if err := initSystemSetting(context.Background(), op.SettingRepo()); err != nil { log.Fatalf("failed to initialize system config: %v", err) } if err := initNotifyTemplate(context.Background(), op.NotifyTemplateRepo()); err != nil { log.Fatalf("failed to initialize notify templates: %v", err) } return nil } func Close() error { if err := op.Close(); err != nil { log.Errorf("failed to close database: %v", err) return err } log.Debugf("database closed") return nil } ================================================ FILE: internal/database/init.go ================================================ package database import ( "context" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/database/op" authModel "github.com/bestruirui/bestsub/internal/models/auth" "github.com/bestruirui/bestsub/internal/models/notify" "github.com/bestruirui/bestsub/internal/models/setting" "github.com/bestruirui/bestsub/internal/utils/log" "golang.org/x/crypto/bcrypt" ) func initAuth(ctx context.Context, auth interfaces.AuthRepository) error { isInitialized, err := auth.IsInitialized(ctx) if err != nil { log.Fatalf("failed to check if database is initialized: %v", err) } if !isInitialized { authData := authModel.Default() hashedBytes, err := bcrypt.GenerateFromPassword([]byte(authData.Password), bcrypt.DefaultCost) if err != nil { log.Fatalf("failed to hash password: %v", err) } authData.Password = string(hashedBytes) if err := auth.Initialize(ctx, &authData); err != nil { log.Fatalf("failed to initialize auth: %v", err) } log.Info("初始化默认管理员账号 用户名: admin 密码: admin") } return nil } func initSystemSetting(ctx context.Context, systemSetting interfaces.SettingRepository) error { defaultSystemSetting := setting.DefaultSetting() existingSystemSetting, err := op.GetAllSetting(ctx) notExistSetting := make([]setting.Setting, 0) if err != nil { log.Fatalf("failed to get existing system setting: %v", err) } existingSystemSettingMap := make(map[string]bool) updateSetting := make([]setting.Setting, 0) for _, item := range existingSystemSetting { existingSystemSettingMap[item.Key] = true } if len(updateSetting) > 0 { if err := systemSetting.Update(ctx, &updateSetting); err != nil { log.Fatalf("failed to update system setting: %v", err) } } for _, s := range defaultSystemSetting { if !existingSystemSettingMap[s.Key] { notExistSetting = append(notExistSetting, s) } } if len(notExistSetting) > 0 { if err := systemSetting.Create(ctx, ¬ExistSetting); err != nil { log.Fatalf("failed to create missing system setting: %v", err) } } return nil } func initNotifyTemplate(ctx context.Context, notifyTemplateRepo interfaces.NotifyTemplateRepository) error { defaultNotifyTemplates := notify.DefaultTemplates() existingNotifyTemplates, err := notifyTemplateRepo.List(ctx) if err != nil { log.Fatalf("failed to get existing notify templates: %v", err) } existingNotifyTemplatesMap := make(map[string]bool) for _, template := range *existingNotifyTemplates { existingNotifyTemplatesMap[template.Type] = true } for _, template := range defaultNotifyTemplates { if !existingNotifyTemplatesMap[template.Type] { if err := notifyTemplateRepo.Create(ctx, &template); err != nil { log.Fatalf("failed to create missing notify template %s: %v", template.Type, err) } } } return nil } ================================================ FILE: internal/database/interfaces/auth.go ================================================ package interfaces import ( "context" "github.com/bestruirui/bestsub/internal/models/auth" ) // 单用户认证数据访问接口 type AuthRepository interface { // 获取认证信息 Get(ctx context.Context) (*auth.Data, error) // 更新用户名 UpdateName(ctx context.Context, name string) error // 更新密码 UpdatePassword(ctx context.Context, hashPassword string) error // 初始化认证信息(首次创建密码) Initialize(ctx context.Context, auth *auth.Data) error // 验证是否已初始化 IsInitialized(ctx context.Context) (bool, error) } ================================================ FILE: internal/database/interfaces/check.go ================================================ package interfaces import ( "context" "github.com/bestruirui/bestsub/internal/models/check" ) type CheckRepository interface { Create(ctx context.Context, check *check.Data) error Update(ctx context.Context, check *check.Data) error Delete(ctx context.Context, id uint16) error GetByID(ctx context.Context, id uint16) (*check.Data, error) List(ctx context.Context) (*[]check.Data, error) } ================================================ FILE: internal/database/interfaces/notify.go ================================================ package interfaces import ( "context" "github.com/bestruirui/bestsub/internal/models/notify" ) // NotificationChannelRepository 通知渠道数据访问接口 type NotifyRepository interface { // Create 创建通知渠道 Create(ctx context.Context, channel *notify.Data) error // GetByID 根据ID获取通知渠道 GetByID(ctx context.Context, id uint16) (*notify.Data, error) // Update 更新通知渠道 Update(ctx context.Context, channel *notify.Data) error // Delete 删除通知渠道 Delete(ctx context.Context, id uint16) error // List 获取通知渠道列表 List(ctx context.Context) (*[]notify.Data, error) } // NotificationTemplateRepository 通知模板数据访问接口 type NotifyTemplateRepository interface { // Create 创建通知模板 Create(ctx context.Context, template *notify.Template) error // GetByType 根据类型获取通知模板 GetByType(ctx context.Context, t string) (*notify.Template, error) // Update 更新通知模板 Update(ctx context.Context, template *notify.Template) error // List 获取通知模板列表 List(ctx context.Context) (*[]notify.Template, error) } ================================================ FILE: internal/database/interfaces/repository.go ================================================ package interfaces // Repository 统一的仓库接口 type Repository interface { Auth() AuthRepository Setting() SettingRepository Notify() NotifyRepository NotifyTemplate() NotifyTemplateRepository Check() CheckRepository Sub() SubRepository Share() ShareRepository Storage() StorageRepository Close() error Migrate() error } ================================================ FILE: internal/database/interfaces/setting.go ================================================ package interfaces import ( "context" "github.com/bestruirui/bestsub/internal/models/setting" ) type SettingRepository interface { Create(ctx context.Context, setting *[]setting.Setting) error GetAll(ctx context.Context) (*[]setting.Setting, error) GetByKey(ctx context.Context, key []string) (*[]setting.Setting, error) Update(ctx context.Context, data *[]setting.Setting) error } ================================================ FILE: internal/database/interfaces/share.go ================================================ package interfaces import ( "context" "github.com/bestruirui/bestsub/internal/models/share" ) // 分享链接数据访问接口 type ShareRepository interface { // Create 创建分享链接 Create(ctx context.Context, shareLink *share.Data) error // GetByID 根据ID获取分享链接 GetByID(ctx context.Context, id uint16) (*share.Data, error) // Update 更新分享链接 Update(ctx context.Context, shareLink *share.Data) error // UpdateAccessCount 更新分享链接访问次数 UpdateAccessCount(ctx context.Context, shareLink *[]share.UpdateAccessCountDB) error // GetConfigByToken 根据token获取分享链接配置 GetGenByToken(ctx context.Context, token string) (string, error) // Delete 删除分享链接 Delete(ctx context.Context, id uint16) error // List 获取分享链接列表 List(ctx context.Context) (*[]share.Data, error) } ================================================ FILE: internal/database/interfaces/storage.go ================================================ package interfaces import ( "context" "github.com/bestruirui/bestsub/internal/models/storage" ) // 存储配置数据访问接口 type StorageRepository interface { // Create 创建存储配置 Create(ctx context.Context, config *storage.Data) error // GetByID 根据ID获取存储配置 GetByID(ctx context.Context, id uint16) (*storage.Data, error) // Update 更新存储配置 Update(ctx context.Context, config *storage.Data) error // Delete 删除存储配置 Delete(ctx context.Context, id uint16) error // List 获取存储配置列表 List(ctx context.Context) (*[]storage.Data, error) } ================================================ FILE: internal/database/interfaces/sub.go ================================================ package interfaces import ( "context" "github.com/bestruirui/bestsub/internal/models/sub" ) // SubRepository 订阅链接数据访问接口 type SubRepository interface { // Create 创建链接 Create(ctx context.Context, link *sub.Data) error // GetByID 根据ID获取链接 GetByID(ctx context.Context, id uint16) (*sub.Data, error) // Update 更新链接 Update(ctx context.Context, link *sub.Data) error // Delete 删除链接 Delete(ctx context.Context, id uint16) error // List 获取订阅链接列表 List(ctx context.Context) (*[]sub.Data, error) // BatchCreate 批量创建订阅链接 BatchCreate(ctx context.Context, links []*sub.Data) error } ================================================ FILE: internal/database/migration/migration.go ================================================ package migration import ( "sort" ) type Info struct { Date uint64 Version string Description string Content func() string } var clientMigrations = make(map[string][]*Info) func Register(client string, date uint64, version, description string, contentFunc func() string) { info := &Info{ Date: date, Version: version, Description: description, Content: contentFunc, } migrations := clientMigrations[client] index := sort.Search(len(migrations), func(i int) bool { return migrations[i].Date > date }) migrations = append(migrations, nil) copy(migrations[index+1:], migrations[index:]) migrations[index] = info clientMigrations[client] = migrations } func Get(client string) []*Info { if migrations := clientMigrations[client]; migrations != nil { clientMigrations = nil return migrations } return make([]*Info, 0) } ================================================ FILE: internal/database/op/auth.go ================================================ package op import ( "context" "fmt" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/models/auth" "golang.org/x/crypto/bcrypt" ) var authRepo interfaces.AuthRepository var authData *auth.Data func AuthRepo() interfaces.AuthRepository { if authRepo == nil { authRepo = repo.Auth() } return authRepo } func AuthGet() (auth.Data, error) { var err error if authData == nil { authData, err = AuthRepo().Get(context.Background()) } return *authData, err } func AuthUpdateName(name string) error { if authData == nil { AuthGet() } authData.UserName = name err := AuthRepo().UpdateName(context.Background(), name) if err != nil { return err } return nil } func AuthUpdatePassWord(password string) error { if authData == nil { AuthGet() } hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } authData.Password = string(hashedBytes) err = AuthRepo().UpdatePassword(context.Background(), authData.Password) if err != nil { return err } return nil } func AuthVerify(username, password string) error { if authData == nil { AuthGet() } if authData.UserName != username { return fmt.Errorf("用户名不匹配") } return bcrypt.CompareHashAndPassword([]byte(authData.Password), []byte(password)) } ================================================ FILE: internal/database/op/check.go ================================================ package op import ( "context" "encoding/json" "fmt" "time" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/models/check" "github.com/bestruirui/bestsub/internal/utils/cache" "github.com/bestruirui/bestsub/internal/utils/log" ) var checkRepo interfaces.CheckRepository var checkCache = cache.New[uint16, check.Data](16) func CheckRepo() interfaces.CheckRepository { if checkRepo == nil { checkRepo = repo.Check() } return checkRepo } func GetCheckByID(id uint16) (check.Data, error) { if checkCache.Len() == 0 { if err := refreshCheckCache(); err != nil { return check.Data{}, err } } if t, ok := checkCache.Get(id); ok { return t, nil } return check.Data{}, fmt.Errorf("check not found") } func CreateCheck(ctx context.Context, t *check.Data) error { if checkCache.Len() == 0 { if err := refreshCheckCache(); err != nil { return err } } if err := CheckRepo().Create(ctx, t); err != nil { return err } checkCache.Set(t.ID, *t) return nil } func UpdateCheck(ctx context.Context, t *check.Data) error { if checkCache.Len() == 0 { if err := refreshCheckCache(); err != nil { return err } } oldCheck, ok := checkCache.Get(t.ID) if !ok { return fmt.Errorf("task not found") } t.Result = oldCheck.Result if err := CheckRepo().Update(ctx, t); err != nil { return err } checkCache.Set(t.ID, *t) return nil } func UpdateCheckResult(id uint16, result check.Result) error { if checkCache.Len() == 0 { if err := refreshCheckCache(); err != nil { log.Errorf("failed to refresh check cache: %v", err) return err } } oldCheck, ok := checkCache.Get(id) if !ok { log.Errorf("check not found") return fmt.Errorf("task not found") } var oldResult check.Result if oldCheck.Result != "" { if err := json.Unmarshal([]byte(oldCheck.Result), &oldResult); err != nil { log.Errorf("failed to unmarshal check result: %v", err) return err } } oldResult.Msg = result.Msg oldResult.Extra = result.Extra oldResult.LastRun = time.Now() oldResult.Duration = result.Duration resultBytes, err := json.Marshal(oldResult) if err != nil { log.Errorf("failed to marshal check result: %v", err) return err } oldCheck.Result = string(resultBytes) if err := CheckRepo().Update(context.Background(), &oldCheck); err != nil { log.Errorf("failed to update check result: %v", err) return err } checkCache.Set(id, oldCheck) return nil } func DeleteCheck(ctx context.Context, id uint16) error { if checkCache.Len() == 0 { if err := refreshCheckCache(); err != nil { return err } } if err := CheckRepo().Delete(ctx, id); err != nil { return err } checkCache.Del(id) return nil } func GetCheckList() ([]check.Data, error) { taskList := checkCache.GetAll() if len(taskList) == 0 { err := refreshCheckCache() if err != nil { return nil, err } taskList = checkCache.GetAll() } var result = make([]check.Data, 0, len(taskList)) for _, v := range taskList { result = append(result, v) } return result, nil } func refreshCheckCache() error { checkCache.Clear() checks, err := CheckRepo().List(context.Background()) if err != nil { return err } for _, check := range *checks { checkCache.Set(check.ID, check) } return nil } ================================================ FILE: internal/database/op/notify.go ================================================ package op import ( "context" "fmt" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/models/notify" "github.com/bestruirui/bestsub/internal/utils/cache" ) var nr interfaces.NotifyRepository var ntr interfaces.NotifyTemplateRepository var notifyTemplateCache = cache.New[string, string](4) var notifyCache = cache.New[uint16, notify.Data](4) func notifyRepo() interfaces.NotifyRepository { if nr == nil { nr = repo.Notify() } return nr } func GetNotifyList() ([]notify.Data, error) { notifyList := notifyCache.GetAll() if len(notifyList) == 0 { err := refreshNotifyCache(context.Background()) if err != nil { return nil, err } notifyList = notifyCache.GetAll() } var result = make([]notify.Data, 0, len(notifyList)) for _, v := range notifyList { result = append(result, v) } return result, nil } func GetNotifyByID(id uint16) (notify.Data, error) { if value, ok := notifyCache.Get(id); ok { return value, nil } err := refreshNotifyCache(context.Background()) if err != nil { return notify.Data{}, err } if value, ok := notifyCache.Get(id); ok { return value, nil } return notify.Data{}, fmt.Errorf("notify not found") } func UpdateNotify(ctx context.Context, n *notify.Data) error { if notifyCache.Len() == 0 { err := refreshNotifyCache(context.Background()) if err != nil { return err } } if err := notifyRepo().Update(ctx, n); err != nil { return err } notifyCache.Set(n.ID, *n) return nil } func CreateNotify(ctx context.Context, n *notify.Data) error { if notifyCache.Len() == 0 { err := refreshNotifyCache(context.Background()) if err != nil { return err } } if err := notifyRepo().Create(ctx, n); err != nil { return err } notifyCache.Set(n.ID, *n) return nil } func DeleteNotify(ctx context.Context, id uint16) error { if notifyCache.Len() == 0 { err := refreshNotifyCache(context.Background()) if err != nil { return err } } if err := notifyRepo().Delete(ctx, id); err != nil { return err } notifyCache.Del(id) return nil } func refreshNotifyCache(ctx context.Context) error { notifyCache.Clear() notifyList, err := notifyRepo().List(ctx) if err != nil { return err } for _, n := range *notifyList { notifyCache.Set(n.ID, n) } return nil } func NotifyTemplateRepo() interfaces.NotifyTemplateRepository { if ntr == nil { ntr = repo.NotifyTemplate() } return ntr } func GetNotifyTemplateList() ([]notify.Template, error) { notifyTemplateList := notifyTemplateCache.GetAll() if len(notifyTemplateList) == 0 { err := refreshNotifyTemplate(context.Background()) if err != nil { return nil, err } notifyTemplateList = notifyTemplateCache.GetAll() } var result = make([]notify.Template, 0, len(notifyTemplateList)) for k, v := range notifyTemplateList { result = append(result, notify.Template{Type: k, Template: v}) } return result, nil } func GetNotifyTemplateByType(t string) (string, error) { if value, ok := notifyTemplateCache.Get(t); ok { return value, nil } err := refreshNotifyTemplate(context.Background()) if err != nil { return "", err } if value, ok := notifyTemplateCache.Get(t); ok { return value, nil } return "", fmt.Errorf("notify template not found") } func UpdateNotifyTemplate(ctx context.Context, nt *notify.Template) error { if notifyTemplateCache.Len() == 0 { refreshNotifyTemplate(context.Background()) } if err := NotifyTemplateRepo().Update(ctx, nt); err != nil { return err } notifyTemplateCache.Set(nt.Type, nt.Template) return nil } func refreshNotifyTemplate(ctx context.Context) error { notifyTemplateCache.Clear() notifyTemplates, err := NotifyTemplateRepo().List(ctx) if err != nil { return err } for _, t := range *notifyTemplates { notifyTemplateCache.Set(t.Type, t.Template) } return nil } ================================================ FILE: internal/database/op/repo.go ================================================ package op import ( "github.com/bestruirui/bestsub/internal/database/interfaces" ) var repo interfaces.Repository func SetRepo(repository interfaces.Repository) { repo = repository } func Close() error { updateAccessCount() return repo.Close() } ================================================ FILE: internal/database/op/setting.go ================================================ package op import ( "context" "fmt" "strconv" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/models/setting" "github.com/bestruirui/bestsub/internal/utils/cache" ) var settingRepo interfaces.SettingRepository var settingCache = cache.New[string, string](4) func SettingRepo() interfaces.SettingRepository { if settingRepo == nil { settingRepo = repo.Setting() } return settingRepo } func GetAllSettingMap(ctx context.Context) (map[string]string, error) { sysConfCache := settingCache.GetAll() if len(sysConfCache) == 0 { err := refreshSettingCache(context.Background()) if err != nil { return nil, err } sysConfCache = settingCache.GetAll() } return sysConfCache, nil } func GetAllSetting(ctx context.Context) ([]setting.Setting, error) { sysConfCache := settingCache.GetAll() if len(sysConfCache) == 0 { err := refreshSettingCache(context.Background()) if err != nil { return nil, err } sysConfCache = settingCache.GetAll() } var result []setting.Setting for key, value := range sysConfCache { result = append(result, setting.Setting{ Key: key, Value: value, }) } return result, nil } func GetSettingByKey(key string) (string, error) { if value, ok := settingCache.Get(key); ok { return value, nil } err := refreshSettingCache(context.Background()) if err != nil { return "", err } if value, ok := settingCache.Get(key); ok { return value, nil } return "", fmt.Errorf("config not found") } func UpdateSetting(ctx context.Context, setting *[]setting.Setting) error { if settingCache.Len() == 0 { err := refreshSettingCache(context.Background()) if err != nil { return err } } if err := SettingRepo().Update(ctx, setting); err != nil { return err } for _, item := range *setting { settingCache.Set(item.Key, item.Value) } return nil } func GetSettingStr(key string) string { value, err := GetSettingByKey(key) if err != nil { return "" } return value } func GetSettingInt(key string) int { value, err := GetSettingByKey(key) if err != nil { return 0 } i, err := strconv.Atoi(value) if err != nil { return 0 } return i } func GetSettingBool(key string) bool { value, err := GetSettingByKey(key) if err != nil { return false } return value == "true" } func refreshSettingCache(ctx context.Context) error { settingCache.Clear() configs, err := SettingRepo().GetAll(ctx) if err != nil { return err } for _, config := range *configs { settingCache.Set(config.Key, config.Value) } return nil } ================================================ FILE: internal/database/op/share.go ================================================ package op import ( "context" "fmt" "sync" "time" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/models/share" "github.com/bestruirui/bestsub/internal/utils/cache" "github.com/bestruirui/bestsub/internal/utils/generic" "github.com/bestruirui/bestsub/internal/utils/log" ) var shareRepo interfaces.ShareRepository var shareCache = cache.New[uint16, share.Data](16) var pendingUpdates = &generic.MapOf[uint16, bool]{} var startOnce sync.Once func ShareRepo() interfaces.ShareRepository { if shareRepo == nil { shareRepo = repo.Share() } return shareRepo } func GetShareList(ctx context.Context) ([]share.Data, error) { shareList := shareCache.GetAll() if len(shareList) == 0 { err := refreshShareCache(context.Background()) if err != nil { return nil, err } shareList = shareCache.GetAll() } var result = make([]share.Data, 0, len(shareList)) for _, v := range shareList { result = append(result, v) } return result, nil } func GetShareByID(ctx context.Context, id uint16) (*share.Data, error) { if shareCache.Len() == 0 { if err := refreshShareCache(ctx); err != nil { return nil, err } } if s, ok := shareCache.Get(id); ok { return &s, nil } return nil, fmt.Errorf("share not found") } func GetShareByToken(ctx context.Context, token string) (*share.Data, error) { if shareCache.Len() == 0 { if err := refreshShareCache(ctx); err != nil { return nil, err } } for _, s := range shareCache.GetAll() { if s.Token == token { return &s, nil } } return nil, fmt.Errorf("share not found") } func CreateShare(ctx context.Context, share *share.Data) error { if shareCache.Len() == 0 { if err := refreshShareCache(ctx); err != nil { return err } } if err := ShareRepo().Create(ctx, share); err != nil { return err } shareCache.Set(share.ID, *share) return nil } func UpdateShare(ctx context.Context, share *share.Data) error { if shareCache.Len() == 0 { if err := refreshShareCache(ctx); err != nil { return err } } oldShare, ok := shareCache.Get(share.ID) if !ok { return fmt.Errorf("share not found") } share.AccessCount = oldShare.AccessCount if err := ShareRepo().Update(ctx, share); err != nil { return err } shareCache.Set(share.ID, *share) return nil } func UpdateShareAccessCount(ctx context.Context, id uint16) error { if shareCache.Len() == 0 { if err := refreshShareCache(ctx); err != nil { return err } } share, ok := shareCache.Get(id) if !ok { return fmt.Errorf("share not found") } share.AccessCount++ shareCache.Set(id, share) pendingUpdates.Store(id, true) startScheduleUpdateAccessCount() return nil } func DeleteShare(ctx context.Context, id uint16) error { if shareCache.Len() == 0 { if err := refreshShareCache(ctx); err != nil { return err } } if err := ShareRepo().Delete(ctx, id); err != nil { return err } shareCache.Del(id) return nil } func refreshShareCache(ctx context.Context) error { shareList, err := ShareRepo().List(ctx) if err != nil { return err } for _, s := range *shareList { shareCache.Set(s.ID, s) } return nil } func startScheduleUpdateAccessCount() { startOnce.Do(func() { ticker := time.NewTicker(60 * time.Second) go func() { defer ticker.Stop() for range ticker.C { updateAccessCount() } }() }) } var updateDataBuffer []share.UpdateAccessCountDB func updateAccessCount() { updateDataBuffer = updateDataBuffer[:0] pendingUpdates.Range(func(id uint16, _ bool) bool { if shareData, ok := shareCache.Get(id); ok { updateDataBuffer = append(updateDataBuffer, share.UpdateAccessCountDB{ ID: id, AccessCount: shareData.AccessCount, }) } return true }) if len(updateDataBuffer) == 0 { return } if err := ShareRepo().UpdateAccessCount(context.Background(), &updateDataBuffer); err != nil { log.Errorf("failed to update share access count: %v", err) return } for _, data := range updateDataBuffer { pendingUpdates.Delete(data.ID) } } ================================================ FILE: internal/database/op/storage.go ================================================ package op import ( "context" "fmt" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/models/storage" "github.com/bestruirui/bestsub/internal/utils/cache" ) var storageRepo interfaces.StorageRepository var storageCache = cache.New[uint16, storage.Data](16) func StorageRepo() interfaces.StorageRepository { if storageRepo == nil { storageRepo = repo.Storage() } return storageRepo } func GetStorageList(ctx context.Context) ([]storage.Data, error) { storageList := storageCache.GetAll() if len(storageList) == 0 { err := refreshStorageCache(context.Background()) if err != nil { return nil, err } storageList = storageCache.GetAll() } var result = make([]storage.Data, 0, len(storageList)) for _, v := range storageList { result = append(result, v) } return result, nil } func GetStorageByID(ctx context.Context, id uint16) (*storage.Data, error) { if storageCache.Len() == 0 { if err := refreshStorageCache(ctx); err != nil { return nil, err } } if s, ok := storageCache.Get(id); ok { return &s, nil } return nil, fmt.Errorf("storage not found") } func CreateStorage(ctx context.Context, storage *storage.Data) error { if storageCache.Len() == 0 { if err := refreshStorageCache(ctx); err != nil { return err } } if err := StorageRepo().Create(ctx, storage); err != nil { return err } storageCache.Set(storage.ID, *storage) return nil } func UpdateStorage(ctx context.Context, storage *storage.Data) error { if storageCache.Len() == 0 { if err := refreshStorageCache(ctx); err != nil { return err } } if err := StorageRepo().Update(ctx, storage); err != nil { return err } storageCache.Set(storage.ID, *storage) return nil } func DeleteStorage(ctx context.Context, id uint16) error { if storageCache.Len() == 0 { if err := refreshStorageCache(ctx); err != nil { return err } } if err := StorageRepo().Delete(ctx, id); err != nil { return err } storageCache.Del(id) return nil } func refreshStorageCache(ctx context.Context) error { storageList, err := StorageRepo().List(ctx) if err != nil { return err } for _, s := range *storageList { storageCache.Set(s.ID, s) } return nil } ================================================ FILE: internal/database/op/sub.go ================================================ package op import ( "context" "encoding/json" "fmt" "github.com/bestruirui/bestsub/internal/database/interfaces" "github.com/bestruirui/bestsub/internal/models/setting" subModel "github.com/bestruirui/bestsub/internal/models/sub" "github.com/bestruirui/bestsub/internal/utils/cache" ) var subRepo interfaces.SubRepository var subCache = cache.New[uint16, subModel.Data](16) func SubRepo() interfaces.SubRepository { if subRepo == nil { subRepo = repo.Sub() } return subRepo } func GetSubList(ctx context.Context) ([]subModel.Data, error) { subList := subCache.GetAll() if len(subList) == 0 { err := refreshSubCache(context.Background()) if err != nil { return nil, err } subList = subCache.GetAll() } var result = make([]subModel.Data, 0, len(subList)) for _, v := range subList { result = append(result, v) } return result, nil } func GetSubByID(ctx context.Context, id uint16) (*subModel.Data, error) { if subCache.Len() == 0 { if err := refreshSubCache(ctx); err != nil { return nil, err } } if s, ok := subCache.Get(id); ok { return &s, nil } return nil, fmt.Errorf("sub not found") } func GetSubNameByID(ctx context.Context, id uint16) string { sub, err := GetSubByID(ctx, id) if err != nil { return "" } return sub.Name } func GetSubTagsByID(ctx context.Context, id uint16) []string { sub, err := GetSubByID(ctx, id) if err != nil { return []string{} } tags := make([]string, 0) err = json.Unmarshal([]byte(sub.Tags), &tags) if err != nil { return []string{} } return tags } func CreateSub(ctx context.Context, sub *subModel.Data) error { if subCache.Len() == 0 { if err := refreshSubCache(ctx); err != nil { return err } } if err := SubRepo().Create(ctx, sub); err != nil { return err } subCache.Set(sub.ID, *sub) return nil } func BatchCreateSub(ctx context.Context, subs []*subModel.Data) error { if subCache.Len() == 0 { if err := refreshSubCache(ctx); err != nil { return err } } if err := SubRepo().BatchCreate(ctx, subs); err != nil { return err } for _, sub := range subs { subCache.Set(sub.ID, *sub) } return nil } func UpdateSub(ctx context.Context, sub *subModel.Data) error { if subCache.Len() == 0 { if err := refreshSubCache(ctx); err != nil { return err } } oldSub, ok := subCache.Get(sub.ID) if !ok { return fmt.Errorf("sub not found") } sub.Result = oldSub.Result sub.CreatedAt = oldSub.CreatedAt if err := SubRepo().Update(ctx, sub); err != nil { return err } subCache.Set(sub.ID, *sub) return nil } func UpdateSubResult(ctx context.Context, id uint16, result subModel.Result) error { if subCache.Len() == 0 { if err := refreshSubCache(ctx); err != nil { return err } } sub, ok := subCache.Get(id) if !ok { return fmt.Errorf("sub not found") } var oldStatus subModel.Result json.Unmarshal([]byte(sub.Result), &oldStatus) result.Success += oldStatus.Success result.Fail += oldStatus.Fail if result.NodeNullCount != 0 { result.NodeNullCount += oldStatus.NodeNullCount } if (result.NodeNullCount > uint16(GetSettingInt(setting.SUB_DISABLE_AUTO))) && GetSettingInt(setting.SUB_DISABLE_AUTO) != 0 { sub.Enable = false } bytes, err := json.Marshal(result) if err != nil { return err } sub.Result = string(bytes) if err := SubRepo().Update(ctx, &sub); err != nil { return err } subCache.Set(id, sub) return nil } func DeleteSub(ctx context.Context, id uint16) error { if subCache.Len() == 0 { if err := refreshSubCache(ctx); err != nil { return err } } if err := SubRepo().Delete(ctx, id); err != nil { return err } subCache.Del(id) return nil } func refreshSubCache(ctx context.Context) error { subList, err := SubRepo().List(ctx) if err != nil { return err } for _, s := range *subList { subCache.Set(s.ID, s) } return nil } ================================================ FILE: internal/models/auth/auth.go ================================================ package auth import "time" type Data struct { ID uint8 `db:"id" json:"-"` UserName string `db:"username" json:"username"` Password string `db:"password" json:"-"` } type LoginRequest struct { Username string `json:"username" binding:"required" example:"admin"` Password string `json:"password" binding:"required" example:"admin"` } type LoginResponse struct { AccessToken string `json:"access_token" example:"access_token_string"` AccessExpiresAt time.Time `json:"access_expires_at" example:"2024-01-01T12:00:00Z"` } type ChangePasswordRequest struct { Username string `json:"username" binding:"required" example:"admin"` OldPassword string `json:"old_password" binding:"required" example:"old_password"` NewPassword string `json:"new_password" binding:"required" example:"new_password"` } type UpdateUserInfoRequest struct { Username string `json:"username" binding:"required" example:"admin"` } type LoginNotify struct { Username string `json:"username"` IP string `json:"ip"` Time string `json:"time"` Msg string `json:"msg"` UserAgent string `json:"user_agent"` } ================================================ FILE: internal/models/auth/default.go ================================================ package auth func Default() Data { return Data{0, "admin", "admin"} } ================================================ FILE: internal/models/check/check.go ================================================ package check import ( "context" "encoding/json" "time" "github.com/bestruirui/bestsub/internal/utils/log" ) type Instance interface { Init() error Run(ctx context.Context, log *log.Logger, subID []uint16) Result } type Data struct { ID uint16 `db:"id" json:"id"` Name string `db:"name" json:"name" description:"检测任务名称"` Enable bool `db:"enable" json:"enable" description:"是否启用"` Task string `db:"task" json:"task" description:"任务配置"` Config string `db:"config" json:"config" description:"检测器配置"` Result string `db:"result" json:"result" description:"检测结果"` } type Task struct { SubIdExclude bool `json:"sub_id_exclude" example:"false" description:"是否排除订阅ID"` SubID []uint16 `json:"sub_id" example:"1" description:"订阅ID"` CronExpr string `json:"cron_expr" example:"0 0 * * *" description:"cron表达式"` Notify bool `json:"notify" example:"true" description:"是否通知"` NotifyChannel int `json:"notify_channel" example:"1" description:"通知渠道"` LogWriteFile bool `json:"log_write_file" example:"true" description:"是否写入日志文件"` LogLevel string `json:"log_level" example:"info" description:"日志级别"` Timeout int `json:"timeout" example:"60" description:"超时时间 分钟"` Type string `json:"type" example:"test" description:"任务类型"` } type Result struct { Msg string `json:"msg" description:"消息"` Extra any `json:"extra" description:"额外信息"` LastRun time.Time `json:"last_run" description:"上次运行时间"` Duration int64 `json:"duration" description:"运行时长(单位:毫秒)"` } type Request struct { Name string `db:"name" json:"name" example:"测试检测任务" description:"检测任务名称"` Enable bool `db:"enable" json:"enable" description:"是否启用"` Task Task `db:"task" json:"task" description:"任务配置"` Config any `db:"config" json:"config" description:"检测器配置"` } type Response struct { ID uint16 `db:"id" json:"id" description:"检测任务ID"` Name string `db:"name" json:"name" description:"检测任务名称"` Enable bool `db:"enable" json:"enable" description:"是否启用"` Task Task `db:"task" json:"task" description:"任务配置"` Config any `db:"config" json:"config" description:"检测器配置"` Status string `db:"-" json:"status" description:"检测状态"` Result Result `db:"result" json:"result" description:"检测结果"` } func (r *Data) GenResponse(status string) Response { var resp Response resp.ID = r.ID resp.Name = r.Name resp.Enable = r.Enable resp.Status = status if err := json.Unmarshal([]byte(r.Task), &resp.Task); err != nil { return resp } if err := json.Unmarshal([]byte(r.Config), &resp.Config); err != nil { return resp } if err := json.Unmarshal([]byte(r.Result), &resp.Result); err != nil { return resp } return resp } func (r *Request) GenData() Data { var data Data taskBytes, err := json.Marshal(r.Task) if err != nil { log.Errorf("failed to marshal task: %v", err) return data } taskStr := string(taskBytes) configBytes, err := json.Marshal(r.Config) if err != nil { log.Errorf("failed to marshal config: %v", err) return data } configStr := string(configBytes) data.Task = taskStr data.Config = configStr data.Name = r.Name data.Enable = r.Enable return data } ================================================ FILE: internal/models/common/base.go ================================================ package common import "time" // BaseDbModel 基础模型,包含所有实体的公共字段 type BaseDbModel struct { ID uint16 `db:"id" json:"id"` Enable bool `db:"enable" json:"enable"` Name string `db:"name" json:"name"` Description string `db:"description" json:"description"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } type BaseRequestModel struct { Enable *bool `json:"enable"` Name string `json:"name"` Description string `json:"description"` } type BaseUpdateRequestModel struct { ID uint16 `json:"id"` Enable *bool `json:"enable"` Name string `json:"name"` Description string `json:"description"` } ================================================ FILE: internal/models/config/config.go ================================================ package config type Base struct { Server ServerConfig `json:"server"` Database DatabaseConfig `json:"database"` Log LogConfig `json:"log"` JWT JWTConfig `json:"jwt"` Session SessionConfig `json:"-"` } type ServerConfig struct { Port int `json:"port"` Host string `json:"host"` UIPath string `json:"-"` } type DatabaseConfig struct { Type string `json:"type"` Path string `json:"-"` } type LogConfig struct { Level string `json:"level"` Output string `json:"output"` Path string `json:"-"` } type JWTConfig struct { Secret string `json:"secret"` } type SessionConfig struct { NodePath string `json:"-"` } ================================================ FILE: internal/models/config/default.go ================================================ package config func DefaultBase() Base { return Base{ Server: ServerConfig{ Port: 8080, Host: "0.0.0.0", }, Database: DatabaseConfig{ Type: "sqlite", }, Log: LogConfig{ Level: "debug", Output: "console", }, } } ================================================ FILE: internal/models/node/node.go ================================================ package node import ( "encoding/json" "github.com/bestruirui/bestsub/internal/utils/generic" "github.com/cespare/xxhash/v2" ) const ( Alive uint64 = 1 << 0 Country uint64 = 1 << 1 TikTok uint64 = 1 << 2 TikTokIDC uint64 = 1 << 3 ) type Data struct { Base Info *Info } type Base struct { Raw []byte SubId uint16 UniqueKey uint64 } type UniqueKey struct { Server string `yaml:"server"` Servername string `yaml:"servername"` Port string `yaml:"port"` Type string `yaml:"type"` Uuid string `yaml:"uuid"` Username string `yaml:"username"` Password string `yaml:"password"` } type Info struct { SpeedUp generic.Queue[uint32] SpeedDown generic.Queue[uint32] Delay generic.Queue[uint16] Risk uint8 AliveStatus uint64 IP uint32 Country string } type SimpleInfo struct { SpeedUp uint32 `json:"speed_up"` SpeedDown uint32 `json:"speed_down"` Delay uint16 `json:"delay"` Risk uint8 `json:"risk"` Count uint32 `json:"count"` } type Filter struct { SubId []uint16 `json:"sub_id"` SubIdExclude bool `json:"sub_id_exclude"` SpeedUpMore uint32 `json:"speed_up_more"` SpeedDownMore uint32 `json:"speed_down_more"` Country []string `json:"country"` CountryExclude bool `json:"country_exclude"` DelayLessThan uint16 `json:"delay_less_than"` AliveStatus uint64 `json:"alive_status"` RiskLessThan uint8 `json:"risk_less_than"` } func (i *Info) SetAliveStatus(AliveStatus uint64, status bool) { if status { i.AliveStatus |= AliveStatus } else { i.AliveStatus &= ^AliveStatus } } func (u *UniqueKey) Gen() uint64 { bytes, _ := json.Marshal(u) return xxhash.Sum64(bytes) } ================================================ FILE: internal/models/notify/default.go ================================================ package notify func DefaultTemplates() []Template { return []Template{ // {"login_success", "登录成功", "{{.Username}}{{.Time}}{{.IP}}{{.UserAgent}}"}, // {"login_failed", "登录失败", "{{.Username}}{{.Time}}{{.IP}}{{.UserAgent}}"}, } } ================================================ FILE: internal/models/notify/notify.go ================================================ package notify import ( "bytes" "encoding/json" ) type Data struct { ID uint16 `db:"id" json:"id"` Name string `db:"name" json:"name"` Type string `db:"type" json:"type"` Config string `db:"config" json:"config"` } type NameAndID struct { ID uint16 `json:"id"` Name string `json:"name"` } type Request struct { Name string `json:"name"` Type string `json:"type"` Config any `json:"config"` } type Response struct { ID uint16 `json:"id"` Name string `json:"name"` Type string `json:"type"` Config any `json:"config"` } type Template struct { Type string `db:"type" json:"type"` Template string `db:"template" json:"template"` } type Instance interface { Init() error Send(title string, body *bytes.Buffer) error } const ( TypeLoginSuccess uint16 = 1 << 0 // 登录成功通知 TypeLoginFailed uint16 = 1 << 1 // 登录失败通知 ) var TypeMap = map[uint16]string{ TypeLoginSuccess: "login_success", TypeLoginFailed: "login_failed", } func (c *Request) GenData(id uint16) Data { configBytes, err := json.Marshal(c.Config) if err != nil { return Data{} } return Data{ ID: id, Name: c.Name, Type: c.Type, Config: string(configBytes), } } func (d *Data) GenResponse() Response { var config any json.Unmarshal([]byte(d.Config), &config) return Response{ ID: d.ID, Name: d.Name, Type: d.Type, Config: config, } } ================================================ FILE: internal/models/setting/default.go ================================================ package setting func DefaultSetting() []Setting { return []Setting{ { Key: PROXY_ENABLE, Value: "false", }, { Key: PROXY_URL, Value: "socks5://user:pass@127.0.0.1:1080", }, { Key: LOG_RETENTION_DAYS, Value: "7", }, { Key: SUBCONV_URL, Value: "", }, { Key: SUBCONV_URL_PROXY, Value: "false", }, { Key: SUB_DISABLE_AUTO, Value: "0", }, { Key: NODE_POOL_SIZE, Value: "1000", }, { Key: NODE_TEST_URL, Value: "https://www.gstatic.com/generate_204", }, { Key: NODE_TEST_TIMEOUT, Value: "5", }, { Key: NODE_PROTOCOL_FILTER_ENABLE, Value: "false", }, { Key: NODE_PROTOCOL_FILTER_MODE, Value: "false", }, { Key: NODE_PROTOCOL_FILTER, Value: "", }, { Key: TASK_MAX_THREAD, Value: "200", }, { Key: TASK_MAX_TIMEOUT, Value: "60", }, { Key: TASK_MAX_RETRY, Value: "3", }, { Key: NOTIFY_OPERATION, Value: "0", }, { Key: NOTIFY_ID, Value: "0", }, } } ================================================ FILE: internal/models/setting/setting.go ================================================ package setting type Setting struct { Key string `json:"key"` Value string `json:"value"` } const ( PROXY_ENABLE = "proxy_enable" PROXY_URL = "proxy_url" LOG_RETENTION_DAYS = "log_retention_days" SUBCONV_URL = "subconv_url" SUBCONV_URL_PROXY = "subconv_url_proxy" SUB_DISABLE_AUTO = "sub_disable_auto" NODE_POOL_SIZE = "node_pool_size" NODE_TEST_URL = "node_test_url" NODE_TEST_TIMEOUT = "node_test_timeout" NODE_PROTOCOL_FILTER_ENABLE = "node_protocol_filter_enable" NODE_PROTOCOL_FILTER_MODE = "node_protocol_filter_mode" NODE_PROTOCOL_FILTER = "node_protocol_filter" TASK_MAX_THREAD = "task_max_thread" TASK_MAX_TIMEOUT = "task_max_timeout" TASK_MAX_RETRY = "task_max_retry" NOTIFY_OPERATION = "notify_operation" NOTIFY_ID = "notify_id" ) ================================================ FILE: internal/models/share/share.go ================================================ package share import ( "encoding/json" nodeModel "github.com/bestruirui/bestsub/internal/models/node" ) type Data struct { ID uint16 `db:"id" json:"id"` Enable bool `db:"enable" json:"enable"` Name string `db:"name" json:"name"` Gen string `db:"gen" json:"gen"` Token string `db:"token" json:"token"` AccessCount uint32 `db:"access_count" json:"access_count"` MaxAccessCount uint32 `db:"max_access_count" json:"max_access_count"` Expires uint64 `db:"expires" json:"expires"` } type GenConfig struct { Filter nodeModel.Filter `json:"filter"` Rename string `json:"rename"` Target string `json:"target"` } type Request struct { Enable bool `json:"enable"` Name string `json:"name"` Token string `json:"token"` Gen GenConfig `json:"gen"` MaxAccessCount uint32 `json:"max_access_count"` Expires uint64 `json:"expires"` } type Response struct { ID uint16 `json:"id"` Name string `json:"name"` Enable bool `json:"enable"` AccessCount uint32 `json:"access_count"` MaxAccessCount uint32 `json:"max_access_count"` Expires uint64 `json:"expires"` Token string `json:"token"` Gen GenConfig `json:"gen"` } type UpdateAccessCountDB struct { ID uint16 `db:"id"` AccessCount uint32 `db:"access_count"` } func (r *Request) GenData() Data { configBytes, err := json.Marshal(r.Gen) if err != nil { return Data{} } return Data{ Enable: r.Enable, Name: r.Name, Token: r.Token, MaxAccessCount: r.MaxAccessCount, Expires: r.Expires, Gen: string(configBytes), } } func (r *Data) GenResponse() Response { var config GenConfig if err := json.Unmarshal([]byte(r.Gen), &config); err != nil { return Response{} } return Response{ ID: r.ID, Name: r.Name, Enable: r.Enable, AccessCount: r.AccessCount, MaxAccessCount: r.MaxAccessCount, Expires: r.Expires, Token: r.Token, Gen: config, } } ================================================ FILE: internal/models/storage/storage.go ================================================ package storage import ( "context" "encoding/json" ) type Data struct { ID uint16 `db:"id" json:"id"` Name string `db:"name" json:"name"` Type string `db:"type" json:"type"` Config string `db:"config" json:"config"` } type Request struct { Name string `json:"name" example:"webdav"` Type string `json:"type" example:"webdav"` Config any `json:"config"` } type Response struct { ID uint16 `json:"id"` Name string `json:"name"` Type string `json:"type"` Config any `json:"config"` } type Instance interface { Init() error Upload(ctx context.Context) error } func (r *Request) GenData(id uint16) Data { configBytes, _ := json.Marshal(r.Config) return Data{ ID: id, Name: r.Name, Type: r.Type, Config: string(configBytes), } } func (d *Data) GenResponse() Response { var config any json.Unmarshal([]byte(d.Config), &config) return Response{ ID: d.ID, Name: d.Name, Type: d.Type, Config: config, } } ================================================ FILE: internal/models/sub/sub.go ================================================ package sub import ( "encoding/json" "time" nodeModel "github.com/bestruirui/bestsub/internal/models/node" ) type Data struct { ID uint16 `db:"id" json:"id"` Enable bool `db:"enable" json:"enable"` Name string `db:"name" json:"name"` Tags string `db:"tags" json:"tags"` CronExpr string `db:"cron_expr" json:"cron_expr"` Config string `db:"config" json:"config"` Result string `db:"result" json:"result"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } type Config struct { Url string `json:"url"` Proxy bool `json:"proxy"` Timeout int `json:"timeout"` ProtocolFilterEnable bool `json:"protocol_filter_enable"` ProtocolFilterMode bool `json:"protocol_filter_mode"` ProtocolFilter []string `json:"protocol_filter"` } type Result struct { Success uint16 `json:"success,omitempty" description:"成功次数"` Fail uint16 `json:"fail,omitempty" description:"失败次数"` NodeNullCount uint16 `json:"node_null_count,omitempty" description:"节点为空次数"` Msg string `json:"msg,omitempty" description:"消息"` RawCount uint32 `json:"raw_count,omitempty" description:"节点数量"` LastRun time.Time `json:"last_run,omitempty" description:"上次运行时间"` Duration uint16 `json:"duration,omitempty" description:"运行时长(单位:毫秒)"` } type Request struct { Name string `json:"name" description:"订阅任务名称"` Tags []string `json:"tags" description:"订阅标签"` Enable bool `json:"enable" description:"是否启用"` CronExpr string `json:"cron_expr" example:"0 0 * * *" description:"cron表达式"` Config Config `json:"config"` } type Response struct { ID uint16 `json:"id" description:"订阅任务ID"` Name string `json:"name" description:"订阅任务名称"` Tags []string `json:"tags" description:"订阅标签"` Enable bool `json:"enable" description:"是否启用"` CronExpr string `json:"cron_expr" description:"cron表达式"` Config Config `json:"config" description:"订阅器配置"` Status string `json:"status" description:"订阅状态"` Result Result `json:"result" description:"订阅结果"` Info nodeModel.SimpleInfo `json:"info" description:"订阅信息"` CreatedAt time.Time `json:"created_at" description:"创建时间"` UpdatedAt time.Time `json:"updated_at" description:"更新时间"` } func (c *Request) GenData(id uint16) Data { configBytes, err := json.Marshal(c.Config) if err != nil { return Data{} } tags, _ := json.Marshal(c.Tags) return Data{ ID: id, Name: c.Name, Tags: string(tags), Enable: c.Enable, CronExpr: c.CronExpr, Config: string(configBytes), } } func (d *Data) GenResponse(status string, subInfo nodeModel.SimpleInfo) Response { var config Config json.Unmarshal([]byte(d.Config), &config) var result Result json.Unmarshal([]byte(d.Result), &result) tags := make([]string, 0) json.Unmarshal([]byte(d.Tags), &tags) return Response{ ID: d.ID, Name: d.Name, Tags: tags, Enable: d.Enable, CronExpr: d.CronExpr, Config: config, Status: status, Result: result, Info: subInfo, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, } } ================================================ FILE: internal/models/system/info.go ================================================ package system // HealthResponse 健康检查响应 type HealthResponse struct { Status string `json:"status" example:"ok"` // 服务状态 Timestamp string `json:"timestamp" example:"2024-01-01T12:00:00"` // 检查时间 Version string `json:"version" example:"1.0.0"` // 版本信息 Database string `json:"database" example:"connected"` // 数据库状态 } // 系统信息结构 type Info struct { MemoryUsed uint64 `json:"memory_used"` // 已使用内存 (bytes) CPUPercent float64 `json:"cpu_percent"` // CPU 占用率 StartTime string `json:"start_time"` // 启动时间 UploadBytes uint64 `json:"upload_bytes"` // 上传流量 (bytes) DownloadBytes uint64 `json:"download_bytes"` // 下载流量 (bytes) } type Version struct { SubConverterVersion string `json:"subconverter_version"` Version string `json:"version"` BuildTime string `json:"build_time"` Commit string `json:"commit"` Author string `json:"author"` Repo string `json:"repo"` } ================================================ FILE: internal/modules/country/channel/cloudflare.go ================================================ package channel import ( "bytes" "encoding/json" "net/http" ) type CloudflareCDN struct{} func (c *CloudflareCDN) Url() string { return "https://cloudflare.com/cdn-cgi/trace" } func (c *CloudflareCDN) Header(req *http.Request) { } func (c *CloudflareCDN) CountryCode(body []byte) string { prefix := []byte("loc=") idx := bytes.Index(body, prefix) if idx == -1 { return "" } start := idx + len(prefix) endRel := bytes.IndexByte(body[start:], '\n') var v []byte if endRel == -1 { v = body[start:] } else { v = body[start : start+endRel] } v = bytes.TrimSpace(v) return string(v) } type CloudflareSpeed struct{} func (c *CloudflareSpeed) Url() string { return "https://speed.cloudflare.com/meta" } func (c *CloudflareSpeed) Header(req *http.Request) { UserAgent(req) } func (c *CloudflareSpeed) CountryCode(body []byte) string { var speed struct { CountryCode string `json:"country"` } if err := json.Unmarshal(body, &speed); err != nil { return "" } return speed.CountryCode } func init() { register(&CloudflareCDN{}) register(&CloudflareSpeed{}) } ================================================ FILE: internal/modules/country/channel/commen.go ================================================ package channel import ( "net/http" "github.com/bestruirui/bestsub/internal/utils/ua" ) type Common struct { CountryCode string `json:"country_code"` } func UserAgent(req *http.Request) { ua.SetHeader(req) } ================================================ FILE: internal/modules/country/channel/freeip.go ================================================ package channel import ( "encoding/json" "net/http" ) type FreeIP struct{} func (c *FreeIP) Url() string { return "https://free.freeipapi.com/api/json" } func (c *FreeIP) Header(req *http.Request) { UserAgent(req) } func (c *FreeIP) CountryCode(body []byte) string { var freeip struct { CountryCode string `json:"countryCode"` } if err := json.Unmarshal(body, &freeip); err != nil { return "" } return freeip.CountryCode } func init() { register(&FreeIP{}) } ================================================ FILE: internal/modules/country/channel/ip_sb.go ================================================ package channel import ( "encoding/json" "net/http" ) type IPSB struct{} func (c *IPSB) Url() string { return "https://api.ip.sb/geoip" } func (c *IPSB) Header(req *http.Request) { UserAgent(req) } func (c *IPSB) CountryCode(body []byte) string { var ip_sb Common if err := json.Unmarshal(body, &ip_sb); err != nil { return "" } return ip_sb.CountryCode } func init() { register(&IPSB{}) } ================================================ FILE: internal/modules/country/channel/ipapi.go ================================================ package channel import ( "encoding/json" "net/http" ) type IPAPI struct{} func (c *IPAPI) Url() string { return "https://ipapi.co/json" } func (c *IPAPI) Header(req *http.Request) { } func (c *IPAPI) CountryCode(body []byte) string { var ipapi Common if err := json.Unmarshal(body, &ipapi); err != nil { return "" } return ipapi.CountryCode } func init() { register(&IPAPI{}) } ================================================ FILE: internal/modules/country/channel/ipwho.go ================================================ package channel import ( "encoding/json" "net/http" ) type IPWho struct{} func (c *IPWho) Url() string { return "https://api.ip.sb/geoip" } func (c *IPWho) Header(req *http.Request) { UserAgent(req) } func (c *IPWho) CountryCode(body []byte) string { var ipwho Common if err := json.Unmarshal(body, &ipwho); err != nil { return "" } return ipwho.CountryCode } func init() { register(&IPWho{}) } ================================================ FILE: internal/modules/country/channel/myip.go ================================================ package channel import ( "encoding/json" "net/http" ) type MYIP struct { CC string `json:"cc"` } func (c *MYIP) Url() string { return "https://api.myip.com" } func (c *MYIP) Header(req *http.Request) { } func (c *MYIP) CountryCode(body []byte) string { var myip MYIP if err := json.Unmarshal(body, &myip); err != nil { return "" } return myip.CC } func init() { register(&MYIP{}) } ================================================ FILE: internal/modules/country/channel/reallyfreegeoip.go ================================================ package channel import ( "encoding/json" "net/http" ) type ReallyFreeGeoIP struct{} func (c *ReallyFreeGeoIP) Url() string { return "https://reallyfreegeoip.org/json" } func (c *ReallyFreeGeoIP) Header(req *http.Request) { UserAgent(req) } func (c *ReallyFreeGeoIP) CountryCode(body []byte) string { var reallyfreegeoip struct { CountryCode string `json:"country_code"` } if err := json.Unmarshal(body, &reallyfreegeoip); err != nil { return "" } return reallyfreegeoip.CountryCode } func init() { register(&ReallyFreeGeoIP{}) } ================================================ FILE: internal/modules/country/channel/register.go ================================================ package channel import ( "net/http" ) type Channel interface { Url() string Header(req *http.Request) CountryCode(body []byte) string } var Channels = make([]Channel, 0) func register(channel Channel) { Channels = append(Channels, channel) } ================================================ FILE: internal/modules/country/country.go ================================================ package country import ( "context" "io" "net/http" "time" "github.com/bestruirui/bestsub/internal/modules/country/channel" ) func GetCode(ctx context.Context, client *http.Client) string { for _, channel := range channel.Channels { ctx, cancel := context.WithTimeout(ctx, time.Second*5) defer cancel() request, err := http.NewRequestWithContext(ctx, "GET", channel.Url(), nil) if err != nil { continue } channel.Header(request) response, err := client.Do(request) if err != nil { continue } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { continue } country := channel.CountryCode(body) if country != "" { return country } body = nil } return "" } ================================================ FILE: internal/modules/notify/channel/email.go ================================================ package channel import ( "bytes" "crypto/tls" "fmt" "net/smtp" "strings" "github.com/bestruirui/bestsub/internal/modules/register" ) type Email struct { Server string `json:"server" require:"true" name:"SMTP服务器"` Port int `json:"port" require:"true" name:"端口"` Username string `json:"username" require:"true" name:"用户名"` Password string `json:"password" require:"true" name:"密码"` From string `json:"from" require:"true" name:"发件人"` To string `json:"to" require:"true" name:"接收人"` TLS bool `json:"tls" require:"true" name:"TLS"` addr string auth smtp.Auth recipients []string } func (e *Email) Init() error { e.addr = fmt.Sprintf("%s:%d", e.Server, e.Port) e.auth = smtp.PlainAuth("", e.Username, e.Password, e.Server) recipients := strings.Split(e.To, ",") e.recipients = make([]string, len(recipients)) for i, recipient := range recipients { e.recipients[i] = strings.TrimSpace(recipient) } return nil } func (e *Email) Send(title string, body *bytes.Buffer) error { if body == nil { return fmt.Errorf("email body is nil") } message := e.buildMessage(title, body) if err := e.sendMail(message); err != nil { return fmt.Errorf("send email failed: %w", err) } return nil } func (e *Email) buildMessage(subject string, body *bytes.Buffer) *bytes.Buffer { var message bytes.Buffer message.WriteString(fmt.Sprintf("From: %s\r\n", e.From)) message.WriteString(fmt.Sprintf("To: %s\r\n", e.To)) message.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) message.WriteString("MIME-Version: 1.0\r\n") message.WriteString("Content-Type: text/html; charset=UTF-8\r\n") message.WriteString("\r\n") body.WriteTo(&message) return &message } func (e *Email) sendMail(message *bytes.Buffer) error { if e.TLS { return e.sendMailWithTLS(message) } else { return smtp.SendMail(e.addr, e.auth, e.From, e.recipients, message.Bytes()) } } func (e *Email) sendMailWithTLS(message *bytes.Buffer) error { tlsConfig := &tls.Config{ ServerName: e.Server, } conn, err := tls.Dial("tcp", e.addr, tlsConfig) if err != nil { return err } defer conn.Close() client, err := smtp.NewClient(conn, e.Server) if err != nil { return err } defer client.Quit() if err := client.Auth(e.auth); err != nil { return err } if err := client.Mail(e.From); err != nil { return err } for _, recipient := range e.recipients { if err := client.Rcpt(recipient); err != nil { return err } } writer, err := client.Data() if err != nil { return err } defer writer.Close() if _, err := writer.Write(message.Bytes()); err != nil { return err } return nil } func init() { register.Notify(&Email{}) } ================================================ FILE: internal/modules/notify/channel/webhook.go ================================================ package channel import ( "bytes" "github.com/bestruirui/bestsub/internal/modules/register" ) type WebHook struct { Url string `json:"url" name:"WebHook地址"` } func (e *WebHook) Init() error { return nil } func (e *WebHook) Send(title string, body *bytes.Buffer) error { return nil } func init() { register.Notify(&WebHook{}) } ================================================ FILE: internal/modules/notify/notify.go ================================================ package notify import ( "bytes" "html/template" "github.com/bestruirui/bestsub/internal/database/op" notifyModel "github.com/bestruirui/bestsub/internal/models/notify" "github.com/bestruirui/bestsub/internal/models/setting" _ "github.com/bestruirui/bestsub/internal/modules/notify/channel" "github.com/bestruirui/bestsub/internal/modules/register" "github.com/bestruirui/bestsub/internal/utils/desc" "github.com/bestruirui/bestsub/internal/utils/log" ) type Desc = desc.Data func SendSystemNotify(operation uint16, title string, content any) error { if operation&uint16(op.GetSettingInt(setting.NOTIFY_OPERATION)) == 0 { return nil } nt, err := op.GetNotifyTemplateByType(notifyModel.TypeMap[operation]) if err != nil { log.Errorf("failed to get notify template: %v", operation) return err } t, err := template.New("notify").Parse(nt) if err != nil { log.Errorf("failed to parse notify template: %v", err) return err } var buf bytes.Buffer err = t.Execute(&buf, content) if err != nil { log.Errorf("failed to execute notify template: %v", err) return err } sysNotifyID := op.GetSettingInt(setting.NOTIFY_ID) notifyConfig, err := op.GetNotifyByID(uint16(sysNotifyID)) if err != nil { log.Errorf("failed to get notify config: %v", sysNotifyID) return err } notify, err := Get(notifyConfig.Type, notifyConfig.Config) if err != nil { log.Errorf("failed to get notify: %v", err) return err } err = notify.Init() if err != nil { log.Errorf("failed to init notify: %v", err) return err } err = notify.Send(title, &buf) if err != nil { log.Errorf("failed to send notify: %v", err) return err } return nil } func Get(m string, c string) (notifyModel.Instance, error) { return register.Get[notifyModel.Instance]("notify", m, c) } func GetChannels() []string { return register.GetList("notify") } func GetInfoMap() map[string][]desc.Data { return register.GetInfoMap("notify") } ================================================ FILE: internal/modules/register/category.go ================================================ package register import ( "github.com/bestruirui/bestsub/internal/models/check" "github.com/bestruirui/bestsub/internal/models/notify" "github.com/bestruirui/bestsub/internal/models/storage" ) func Notify(i notify.Instance) { register("notify", i) } func Check(i check.Instance) { register("check", i) } func Storage(i storage.Instance) { register("storage", i) } ================================================ FILE: internal/modules/register/register.go ================================================ package register import ( "encoding/json" "errors" "reflect" "strings" "github.com/bestruirui/bestsub/internal/utils/desc" ) type registerInfo struct { im map[string]any aim map[string][]desc.Data } var registers = map[string]*registerInfo{} func register(t string, i any) { r, exists := registers[t] if !exists { r = ®isterInfo{ im: make(map[string]any), aim: make(map[string][]desc.Data), } registers[t] = r } m := strings.ToLower(reflect.TypeOf(i).Elem().Name()) r.im[m] = i r.aim[m] = desc.Gen(i) } func Get[T any](t string, m string, c string) (T, error) { ri, exists := registers[t] if !exists { return *new(T), errors.New("category not found") } info, exists := ri.im[m] if !exists { return *new(T), errors.New("item not found") } ni := reflect.New(reflect.TypeOf(info).Elem()).Interface() if c != "" { err := json.Unmarshal([]byte(c), ni) if err != nil { return *new(T), err } } return ni.(T), nil } func GetList(t string) []string { ri, exists := registers[t] if !exists { return nil } keys := make([]string, 0, len(ri.im)) for k := range ri.im { keys = append(keys, k) } return keys } func GetInfoMap(t string) map[string][]desc.Data { ri, exists := registers[t] if !exists { return nil } return ri.aim } ================================================ FILE: internal/modules/share/share.go ================================================ package share import ( "bytes" "context" "encoding/json" "fmt" "strings" "text/template" "github.com/bestruirui/bestsub/internal/core/node" "github.com/bestruirui/bestsub/internal/core/subconv" "github.com/bestruirui/bestsub/internal/database/op" "github.com/bestruirui/bestsub/internal/models/share" "github.com/bestruirui/bestsub/internal/utils" "github.com/bestruirui/bestsub/internal/utils/country" ) func GenSubData(genConfigStr string) []byte { var genConfig share.GenConfig if err := json.Unmarshal([]byte(genConfigStr), &genConfig); err != nil { return nil } nodes := node.GetByFilter(genConfig.Filter) var result bytes.Buffer result.Write(nodeData) tmpl, err := renameTemplate.Parse(genConfig.Rename) if err != nil { return nil } var newName bytes.Buffer for i, node := range *nodes { newName.Reset() result.Write(dash) subTags := op.GetSubTagsByID(context.Background(), node.Base.SubId) simpleInfo := renameTmpl{ SpeedUp: node.Info.SpeedUp.Average(), SpeedDown: node.Info.SpeedDown.Average(), Delay: uint32(node.Info.Delay.Average()), Risk: uint32(node.Info.Risk), Count: uint32(i + 1), Country: country.GetCountry(node.Info.Country), IP: utils.Uint32ToIP(node.Info.IP), SubName: op.GetSubNameByID(context.Background(), node.Base.SubId), SubTags: fmt.Sprintf("<%s>", strings.Join(subTags, "|")), SubTagsOrigin: subTags, } tmpl.Execute(&newName, simpleInfo) result.Write(rename(node.Base.Raw, newName.Bytes())) result.Write(newLine) } resultStr := subconv.ConvertData(result.String(), genConfig.Target) return []byte(resultStr) } func GenNodeData(config string) []byte { var genConfig share.GenConfig if err := json.Unmarshal([]byte(config), &genConfig); err != nil { return nil } nodes := node.GetByFilter(genConfig.Filter) var result bytes.Buffer result.Write(nodeData) tmpl, err := renameTemplate.Parse(genConfig.Rename) if err != nil { return nil } var newName bytes.Buffer for i, node := range *nodes { newName.Reset() result.Write(dash) subTags := op.GetSubTagsByID(context.Background(), node.Base.SubId) simpleInfo := renameTmpl{ SpeedUp: node.Info.SpeedUp.Average(), SpeedDown: node.Info.SpeedDown.Average(), Delay: uint32(node.Info.Delay.Average()), Risk: uint32(node.Info.Risk), Count: uint32(i + 1), Country: country.GetCountry(node.Info.Country), IP: utils.Uint32ToIP(node.Info.IP), SubName: op.GetSubNameByID(context.Background(), node.Base.SubId), SubTags: fmt.Sprintf("<%s>", strings.Join(subTags, "|")), SubTagsOrigin: subTags, } tmpl.Execute(&newName, simpleInfo) result.Write(rename(node.Base.Raw, newName.Bytes())) result.Write(newLine) } return result.Bytes() } func rename(raw []byte, newName []byte) []byte { var node map[string]any if err := json.Unmarshal(raw, &node); err != nil { return raw } node["name"] = string(newName) out, err := json.Marshal(node) if err != nil { return raw } return out } var ( nodeData = []byte("proxies:\n") newLine = []byte("\n") dash = []byte(" - ") ) type renameTmpl struct { SpeedUp uint32 SpeedDown uint32 Delay uint32 Risk uint32 Country country.Country Count uint32 IP string SubName string SubTags string SubTagsOrigin []string } var renameTemplate = template.New("node").Funcs(template.FuncMap{ "add": func(x, y uint32) uint32 { return x + y }, "sub": func(x, y uint32) uint32 { return x - y }, "div": func(x, y uint32) uint32 { if y == 0 { return 0 } return x / y }, "mod": func(x, y uint32) uint32 { if y == 0 { return 0 } return x % y }, }) ================================================ FILE: internal/modules/storage/channel/webdav.go ================================================ package channel import ( "context" "github.com/bestruirui/bestsub/internal/modules/register" ) func init() { register.Storage(&WebDAV{}) } type WebDAV struct { url string `json:"url" type:"string" required:"true" description:"WebDAV地址"` username string `json:"username" type:"string" required:"true" description:"WebDAV用户名"` password string `json:"password" type:"string" required:"true" description:"WebDAV密码"` } func (w *WebDAV) Init() error { return nil } func (w *WebDAV) Upload(ctx context.Context) error { return nil } ================================================ FILE: internal/modules/storage/storage.go ================================================ package storage import ( storageModel "github.com/bestruirui/bestsub/internal/models/storage" "github.com/bestruirui/bestsub/internal/modules/register" _ "github.com/bestruirui/bestsub/internal/modules/storage/channel" "github.com/bestruirui/bestsub/internal/utils/desc" ) type Desc = desc.Data func Get(m string, c string) (storageModel.Instance, error) { return register.Get[storageModel.Instance]("storage", m, c) } func GetChannels() []string { return register.GetList("storage") } func GetInfoMap() map[string][]Desc { return register.GetInfoMap("storage") } ================================================ FILE: internal/server/auth/auth.go ================================================ package auth import ( "fmt" "time" "github.com/bestruirui/bestsub/internal/models/auth" "github.com/golang-jwt/jwt/v5" ) // Claims JWT声明结构 type Claims struct { Username string `json:"username"` jwt.RegisteredClaims } // GenerateToken 生成访问令牌 func GenerateToken(username, secret string) (*auth.LoginResponse, error) { now := time.Now() accessExpiresAt := now.Add(7 * 24 * time.Hour) claims := &Claims{ Username: username, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(accessExpiresAt), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), Issuer: "bestsub", Subject: username, }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) accessToken, err := token.SignedString([]byte(secret)) if err != nil { return nil, fmt.Errorf("failed to sign token: %w", err) } return &auth.LoginResponse{ AccessToken: accessToken, AccessExpiresAt: accessExpiresAt, }, nil } // ValidateToken 验证JWT令牌 func ValidateToken(tokenString, secret string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return []byte(secret), nil }) if err != nil { return nil, fmt.Errorf("failed to parse token: %w", err) } if claims, ok := token.Claims.(*Claims); ok && token.Valid { if claims.ExpiresAt != nil && time.Now().After(claims.ExpiresAt.Time) { return nil, fmt.Errorf("token has expired") } return claims, nil } return nil, fmt.Errorf("invalid token") } ================================================ FILE: internal/server/handlers/auth.go ================================================ package handlers import ( "net/http" "time" "github.com/bestruirui/bestsub/internal/config" "github.com/bestruirui/bestsub/internal/database/op" authModel "github.com/bestruirui/bestsub/internal/models/auth" notifyModel "github.com/bestruirui/bestsub/internal/models/notify" "github.com/bestruirui/bestsub/internal/modules/notify" "github.com/bestruirui/bestsub/internal/server/auth" "github.com/bestruirui/bestsub/internal/server/middleware" "github.com/bestruirui/bestsub/internal/server/resp" "github.com/bestruirui/bestsub/internal/server/router" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/gin-gonic/gin" ) func init() { router.NewGroupRouter("/api/v1/auth"). AddRoute( router.NewRoute("/login", router.POST). Handle(login), ) router.NewGroupRouter("/api/v1/auth"). Use(middleware.Auth()). AddRoute( router.NewRoute("/logout", router.POST). Handle(logout), ). AddRoute( router.NewRoute("/user/password", router.POST). Handle(changePassword), ). AddRoute( router.NewRoute("/user/name", router.POST). Handle(updateUsername), ). AddRoute( router.NewRoute("/user", router.GET). Handle(getUserInfo), ) } // login 用户登录 // @Summary 用户登录 // @Description 用户登录接口,验证用户名和密码,返回JWT令牌 // @Tags 认证 // @Accept json // @Produce json // @Param request body authModel.LoginRequest true "登录请求" // @Success 200 {object} resp.ResponseStruct{data=authModel.LoginResponse} "登录成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "用户名或密码错误" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/auth/login [post] func login(c *gin.Context) { var req authModel.LoginRequest if err := c.ShouldBindJSON(&req); err != nil { resp.ErrorBadRequest(c) return } err := op.AuthVerify(req.Username, req.Password) if err != nil { log.Warnf("Login failed for user %s: %v from %s", req.Username, err, c.ClientIP()) go notify.SendSystemNotify(notifyModel.TypeLoginFailed, "登录失败", authModel.LoginNotify{ Username: req.Username, IP: c.ClientIP(), Time: time.Now().Format("2006-01-02 15:04:05"), Msg: "登录失败,用户名或密码错误", UserAgent: c.GetHeader("User-Agent"), }) resp.Error(c, http.StatusUnauthorized, "username or password error") return } token, err := auth.GenerateToken(req.Username, config.Base().JWT.Secret) if err != nil { log.Errorf("Failed to generate token: %v from %s", err, c.ClientIP()) go notify.SendSystemNotify(notifyModel.TypeLoginFailed, "登录失败", authModel.LoginNotify{ Username: req.Username, IP: c.ClientIP(), Time: time.Now().Format("2006-01-02 15:04:05"), Msg: "登录失败,生成令牌失败", UserAgent: c.GetHeader("User-Agent"), }) resp.Error(c, http.StatusInternalServerError, "failed to generate token") return } log.Infof("User %s logged in successfully from %s", req.Username, c.ClientIP()) go notify.SendSystemNotify(notifyModel.TypeLoginSuccess, "登录成功", authModel.LoginNotify{ Username: req.Username, IP: c.ClientIP(), Time: time.Now().Format("2006-01-02 15:04:05"), Msg: "登录成功", UserAgent: c.GetHeader("User-Agent"), }) resp.Success(c, token) } // logout 用户登出 // @Summary 用户登出 // @Description 用户登出接口,客户端清除令牌 // @Tags 认证 // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} resp.ResponseStruct "登出成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/auth/logout [post] func logout(c *gin.Context) { log.Infof("User logged out successfully from %s", c.ClientIP()) resp.Success(c, nil) } // changePassword 修改密码 // @Summary 修改密码 // @Description 修改当前用户的密码 // @Tags 认证 // @Accept json // @Produce json // @Security BearerAuth // @Param request body authModel.ChangePasswordRequest true "修改密码请求" // @Success 200 {object} resp.ResponseStruct "密码修改成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权或旧密码错误" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/auth/user/password [post] func changePassword(c *gin.Context) { var req authModel.ChangePasswordRequest if err := c.ShouldBindJSON(&req); err != nil { resp.ErrorBadRequest(c) return } err := op.AuthVerify(req.Username, req.OldPassword) if err != nil { log.Warnf("Change password failed for user %s: old password verification failed from %s", req.Username, c.ClientIP()) resp.Error(c, http.StatusUnauthorized, "old password verification failed") return } err = op.AuthUpdatePassWord(req.NewPassword) if err != nil { log.Errorf("Failed to update password: %v", err) resp.Error(c, http.StatusInternalServerError, "failed to update password") return } log.Infof("Password changed successfully for user %s from %s", req.Username, c.ClientIP()) resp.Success(c, nil) } // getUserInfo 获取当前用户信息 // @Summary 获取用户信息 // @Description 获取当前登录用户的详细信息 // @Tags 认证 // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} resp.ResponseStruct{data=authModel.Data} "获取成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/auth/user [get] func getUserInfo(c *gin.Context) { authInfo, err := op.AuthGet() if err != nil { log.Errorf("Failed to get auth info from %s: %v", c.ClientIP(), err) resp.Error(c, http.StatusInternalServerError, "failed to get auth info") return } resp.Success(c, authInfo) } // updateUsername 修改用户名 // @Summary 修改用户名 // @Description 修改当前用户的用户名 // @Tags 认证 // @Accept json // @Produce json // @Security BearerAuth // @Param request body authModel.UpdateUserInfoRequest true "修改用户名请求" // @Success 200 {object} resp.ResponseStruct "用户名修改成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 409 {object} resp.ResponseStruct "用户名已存在" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/auth/user/name [post] func updateUsername(c *gin.Context) { var req authModel.UpdateUserInfoRequest if err := c.ShouldBindJSON(&req); err != nil { resp.ErrorBadRequest(c) return } authInfo, err := op.AuthGet() if err != nil { log.Errorf("Failed to get auth info from %s: %v", c.ClientIP(), err) resp.Error(c, http.StatusInternalServerError, "failed to get auth info") return } if authInfo.UserName == req.Username { resp.Error(c, http.StatusBadRequest, "new username cannot be the same as current username") return } if err := op.AuthUpdateName(req.Username); err != nil { resp.Error(c, http.StatusInternalServerError, "failed to update username") return } log.Infof("Username changed successfully from %s to %s from %s", authInfo.UserName, req.Username, c.ClientIP()) resp.Success(c, nil) } ================================================ FILE: internal/server/handlers/check.go ================================================ package handlers import ( "fmt" "net/http" "strconv" "github.com/bestruirui/bestsub/internal/core/check" "github.com/bestruirui/bestsub/internal/core/cron" "github.com/bestruirui/bestsub/internal/database/op" checkModel "github.com/bestruirui/bestsub/internal/models/check" "github.com/bestruirui/bestsub/internal/server/middleware" "github.com/bestruirui/bestsub/internal/server/resp" "github.com/bestruirui/bestsub/internal/server/router" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/gin-gonic/gin" ) func init() { router.NewGroupRouter("/api/v1/check"). Use(middleware.Auth()). AddRoute( router.NewRoute("/type", router.GET). Handle(getCheckTypes), ). AddRoute( router.NewRoute("", router.POST). Handle(createCheck), ). AddRoute( router.NewRoute("", router.GET). Handle(getCheck), ). AddRoute( router.NewRoute("/:id", router.PUT). Handle(updateCheck), ). AddRoute( router.NewRoute("/:id", router.DELETE). Handle(deleteCheck), ). AddRoute( router.NewRoute("/:id/run", router.POST). Handle(runCheck), ). AddRoute( router.NewRoute("/:id/stop", router.POST). Handle(stopCheck), ) } // getCheckTypes 获取检测类型 // @Summary 获取检测类型 // @Description 获取检测类型 // @Tags 检测 // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} resp.ResponseStruct{data=map[string][]check.Desc} "获取成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/check/type [get] func getCheckTypes(c *gin.Context) { resp.Success(c, check.GetInfoMap()) } // createCheck 创建检测 // @Summary 创建检测 // @Description 创建单个检测 // @Tags 检测 // @Accept json // @Produce json // @Security BearerAuth // @Param request body checkModel.Request true "创建检测请求" // @Success 200 {object} resp.ResponseStruct{data=checkModel.Response} "创建成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/check [post] func createCheck(c *gin.Context) { var req checkModel.Request if err := c.ShouldBindJSON(&req); err != nil { resp.ErrorBadRequest(c) return } checkData := req.GenData() if err := op.CreateCheck(c.Request.Context(), &checkData); err != nil { log.Errorf("failed to create check: %v", err) resp.Error(c, http.StatusInternalServerError, err.Error()) return } cron.CheckAdd(&checkData) resp.Success(c, checkData.GenResponse(cron.CheckStatus(checkData.ID))) } // getCheck 获取检测列表 // @Summary 获取检测列表 // @Tags 检测 // @Accept json // @Produce json // @Security BearerAuth // @Param id query int true "检测ID" // @Success 200 {object} resp.ResponseStruct{data=[]checkModel.Response} "获取成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/check [get] func getCheck(c *gin.Context) { idStr := c.Query("id") if idStr == "" { checkList, err := op.GetCheckList() if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } var respCheckList = make([]checkModel.Response, len(checkList)) for i := range checkList { respCheckList[i] = checkList[i].GenResponse(cron.CheckStatus(checkList[i].ID)) } resp.Success(c, respCheckList) } else { id, err := strconv.ParseUint(idStr, 10, 16) if err != nil { resp.ErrorBadRequest(c) return } check, err := op.GetCheckByID(uint16(id)) if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } var respCheck = make([]checkModel.Response, 1) respCheck[0] = check.GenResponse(cron.CheckStatus(check.ID)) resp.Success(c, respCheck) } } // updateCheck 更新检测 // @Summary 更新检测 // @Description 根据请求体中的ID更新检测信息 // @Tags 检测 // @Accept json // @Produce json // @Security BearerAuth // @Param id path int true "检测ID" // @Param request body checkModel.Request true "更新检测请求" // @Success 200 {object} resp.ResponseStruct{data=checkModel.Response} "更新成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 404 {object} resp.ResponseStruct "检测不存在" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/check/{id} [put] func updateCheck(c *gin.Context) { var req checkModel.Request if err := c.ShouldBindJSON(&req); err != nil { resp.ErrorBadRequest(c) return } idStr := c.Param("id") if idStr == "" { resp.ErrorBadRequest(c) return } id, err := strconv.ParseUint(idStr, 10, 16) if err != nil { resp.ErrorBadRequest(c) return } checkData := req.GenData() checkData.ID = uint16(id) if err := op.UpdateCheck(c.Request.Context(), &checkData); err != nil { log.Errorf("failed to update check: %v", err) resp.Error(c, http.StatusInternalServerError, err.Error()) return } if err := cron.CheckUpdate(&checkData); err != nil { log.Errorf("failed to update check: %v", err) resp.Error(c, http.StatusInternalServerError, err.Error()) return } resp.Success(c, checkData.GenResponse(cron.CheckStatus(checkData.ID))) } // deleteCheck 删除检测 // @Summary 删除检测 // @Description 根据ID删除单个检测 // @Tags 检测 // @Accept json // @Produce json // @Security BearerAuth // @Param id path int true "检测ID" // @Success 200 {object} resp.ResponseStruct "删除成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 404 {object} resp.ResponseStruct "检测不存在" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/check/{id} [delete] func deleteCheck(c *gin.Context) { idParam := c.Param("id") if idParam == "" { resp.Error(c, http.StatusBadRequest, "check id is required") return } id, err := strconv.ParseUint(idParam, 10, 16) if err != nil { resp.ErrorBadRequest(c) return } if err := op.DeleteCheck(c.Request.Context(), uint16(id)); err != nil { resp.Error(c, http.StatusInternalServerError, "failed to delete check") return } if err := cron.CheckRemove(uint16(id)); err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } if err := log.DeleteLog(fmt.Sprintf("check/%d", id)); err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } resp.Success(c, nil) } // runCheck 手动运行检测 // @Summary 手动运行检测 // @Description 手动触发检测执行 // @Tags 检测 // @Accept json // @Produce json // @Security BearerAuth // @Param id path int true "检测ID" // @Success 200 {object} resp.ResponseStruct "运行成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 404 {object} resp.ResponseStruct "检测不存在" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/check/{id}/run [post] func runCheck(c *gin.Context) { idParam := c.Param("id") if idParam == "" { resp.Error(c, http.StatusBadRequest, "check id is required") return } id, err := strconv.ParseUint(idParam, 10, 16) if err != nil { resp.ErrorBadRequest(c) return } if err := cron.CheckRun(uint16(id)); err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } log.Debugf("Check %d manually run from %s", id, c.ClientIP()) resp.Success(c, nil) } // stopCheck 停止检测 // @Summary 停止检测 // @Description 停止正在运行的检测 // @Tags 检测 // @Accept json // @Produce json // @Security BearerAuth // @Param id path int true "检测ID" // @Success 200 {object} resp.ResponseStruct "停止成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 404 {object} resp.ResponseStruct "检测不存在" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/check/{id}/stop [post] func stopCheck(c *gin.Context) { idParam := c.Param("id") if idParam == "" { resp.Error(c, http.StatusBadRequest, "task id is required") return } id, err := strconv.ParseUint(idParam, 10, 16) if err != nil { resp.ErrorBadRequest(c) return } if err := cron.CheckStop(uint16(id)); err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } log.Infof("Check %d stopped from %s", id, c.ClientIP()) resp.Success(c, nil) } ================================================ FILE: internal/server/handlers/info.go ================================================ package handlers import ( "context" "net/http" "time" sys "github.com/bestruirui/bestsub/internal/core/system" "github.com/bestruirui/bestsub/internal/database/op" "github.com/bestruirui/bestsub/internal/models/system" "github.com/bestruirui/bestsub/internal/server/middleware" "github.com/bestruirui/bestsub/internal/server/resp" "github.com/bestruirui/bestsub/internal/server/router" "github.com/bestruirui/bestsub/internal/utils/info" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/gin-gonic/gin" ) func init() { router.NewGroupRouter("/api/v1/system"). AddRoute( router.NewRoute("/health", router.GET). Handle(healthCheck), ). AddRoute( router.NewRoute("/ready", router.GET). Handle(readinessCheck), ). AddRoute( router.NewRoute("/live", router.GET). Handle(livenessCheck), ) router.NewGroupRouter("/api/v1/system"). Use(middleware.Auth()). AddRoute( router.NewRoute("/info", router.GET). Handle(systemInfo), ). AddRoute( router.NewRoute("/version", router.GET). Handle(version), ) } // healthCheck 健康检查 // @Summary 健康检查 // @Description 检查服务健康状态,包括数据库连接状态 // @Tags 系统 // @Accept json // @Produce json // @Success 200 {object} resp.ResponseStruct{data=system.HealthResponse} "服务正常" // @Failure 503 {object} resp.ResponseStruct "服务不可用" // @Router /api/v1/system/health [get] func healthCheck(c *gin.Context) { // 检查数据库连接状态 opStatus := "connected" // 尝试执行一个简单的数据库查询来检查连接 authRepo := op.AuthRepo() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, err := authRepo.IsInitialized(ctx) if err != nil { log.Errorf("Database health check failed: %v", err) opStatus = "disconnected" } response := system.HealthResponse{ Status: "ok", Timestamp: time.Now().Format(time.RFC3339), Version: info.Version, Database: opStatus, } // 如果数据库连接失败,返回503状态码 if opStatus == "disconnected" { response.Status = "error" resp.Error(c, http.StatusServiceUnavailable, "database connection failed") return } resp.Success(c, response) } // readinessCheck 就绪检查 // @Summary 就绪检查 // @Description 检查服务是否准备好接收请求 // @Tags 系统 // @Accept json // @Produce json // @Success 200 {object} resp.ResponseStruct{data=system.HealthResponse} "服务就绪" // @Failure 503 {object} resp.ResponseStruct "服务未就绪" // @Router /api/v1/system/ready [get] func readinessCheck(c *gin.Context) { // 检查关键组件是否就绪 ready := true var errorMsg string authRepo := op.AuthRepo() ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() isInitialized, err := authRepo.IsInitialized(ctx) if err != nil || !isInitialized { ready = false errorMsg = "database not initialized" log.Errorf("Readiness check failed: op not initialized, error: %v", err) } response := system.HealthResponse{ Status: "ready", Timestamp: time.Now().Format(time.RFC3339), Version: info.Version, Database: "initialized", } if !ready { response.Status = "not ready" response.Database = "not initialized" resp.Error(c, http.StatusServiceUnavailable, errorMsg) return } resp.Success(c, response) } // livenessCheck 存活检查 // @Summary 存活检查 // @Description 检查服务是否存活(简单的ping检查) // @Tags 系统 // @Accept json // @Produce json // @Success 200 {object} resp.ResponseStruct "服务存活" // @Router /api/v1/system/live [get] func livenessCheck(c *gin.Context) { resp.Success(c, map[string]interface{}{ "status": "alive", "timestamp": time.Now().Format(time.RFC3339), }) } // systemInfo 系统信息 // @Summary 系统信息 // @Description 获取程序运行相关信息,包括内存使用、运行时长、网络流量、CPU信息等 // @Tags 系统 // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} resp.ResponseStruct{data=system.Info} "获取成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/system/info [get] func systemInfo(c *gin.Context) { resp.Success(c, sys.GetSystemInfo()) } // systemInfo 系统版本 // @Summary 系统版本 // @Description 获取程序版本信息 // @Tags 系统 // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} resp.ResponseStruct{data=system.Version} "获取成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/system/version [get] func version(c *gin.Context) { resp.Success(c, system.Version{ Version: info.Version, BuildTime: info.BuildTime, Commit: info.Commit, Author: info.Author, Repo: info.Repo, }) } ================================================ FILE: internal/server/handlers/log.go ================================================ package handlers import ( "net/http" "strconv" "strings" "github.com/bestruirui/bestsub/internal/server/middleware" "github.com/bestruirui/bestsub/internal/server/resp" "github.com/bestruirui/bestsub/internal/server/router" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/gin-gonic/gin" ) func init() { router.NewGroupRouter("/api/v1/log"). Use(middleware.Auth()). AddRoute( router.NewRoute("/list", router.GET). Handle(getLogFileList), ). AddRoute( router.NewRoute("/content", router.GET). Handle(getLogContent), ) } // @Summary 获取日志列表 // @Description 获取日志列表 // @Tags 日志 // @Accept json // @Produce json // @Security BearerAuth // @Param path query string true "日志文件路径" // @Success 200 {object} resp.ResponseStruct{data=[]uint64} "获取成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/log/list [get] func getLogFileList(c *gin.Context) { path := c.Query("path") if path == "" { resp.Error(c, http.StatusBadRequest, "path parameter is required") return } logFileList, err := log.GetLogFileList(path) if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } resp.Success(c, logFileList) } // @Summary 获取日志内容 // @Description 获取日志内容 // @Tags 日志 // @Accept json // @Produce json // @Security BearerAuth // @Param path query string true "日志文件路径" // @Param timestamp query uint64 true "日志文件时间戳" // @Success 200 {object} resp.ResponseStruct{data=[]object{level=string,time=string,msg=string}} "获取成功" // @Failure 400 {object} resp.ResponseStruct "参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 404 {object} resp.ResponseStruct "文件不存在" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/log/content [get] func getLogContent(c *gin.Context) { path := c.Query("path") if path == "" { resp.Error(c, http.StatusBadRequest, "path parameter is required") return } timestampStr := c.Query("timestamp") if timestampStr == "" { resp.Error(c, http.StatusBadRequest, "timestamp parameter is required") return } timestamp, err := strconv.ParseUint(timestampStr, 10, 64) if err != nil { resp.Error(c, http.StatusBadRequest, "invalid timestamp format") return } c.Header("Content-Type", "application/json; charset=utf-8") c.Header("Transfer-Encoding", "chunked") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") c.Status(http.StatusOK) w := c.Writer w.WriteString(`{"code":200,"message":"success","data":[`) w.Flush() err = log.StreamLogToHTTP(path, timestamp, w) if err != nil { w.WriteString(`],"error":"`) w.WriteString(strings.ReplaceAll(err.Error(), `"`, `\"`)) w.WriteString(`"}`) w.Flush() return } w.WriteString(`]}`) w.Flush() } ================================================ FILE: internal/server/handlers/notify.go ================================================ package handlers import ( "bytes" "fmt" "net/http" "slices" "strconv" "github.com/bestruirui/bestsub/internal/database/op" notifyModel "github.com/bestruirui/bestsub/internal/models/notify" "github.com/bestruirui/bestsub/internal/modules/notify" "github.com/bestruirui/bestsub/internal/server/middleware" "github.com/bestruirui/bestsub/internal/server/resp" "github.com/bestruirui/bestsub/internal/server/router" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/gin-gonic/gin" ) func init() { router.NewGroupRouter("/api/v1/notify"). Use(middleware.Auth()). AddRoute( router.NewRoute("/channel", router.GET). Handle(getNotifyChannel), ). AddRoute( router.NewRoute("/channel/config", router.GET). Handle(getNotifyChannelConfig), ). AddRoute( router.NewRoute("/name", router.GET). Handle(getNotifyNameAndID), ). AddRoute( router.NewRoute("", router.GET). Handle(getNotifyList), ). AddRoute( router.NewRoute("", router.POST). Handle(createNotify), ). AddRoute( router.NewRoute("", router.PUT). Handle(updateNotify), ). AddRoute( router.NewRoute("", router.DELETE). Handle(deleteNotify), ). AddRoute( router.NewRoute("/test", router.POST). Handle(testNotify), ). AddRoute( router.NewRoute("/template", router.GET). Handle(getTemplates), ). AddRoute( router.NewRoute("/template", router.PUT). Handle(updateTemplate), ) } // getNotifyConfig 获取通知渠道 // @Summary 获取通知渠道 // @Tags 通知 // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} resp.ResponseStruct{data=[]string} "获取成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/notify/channel [get] func getNotifyChannel(c *gin.Context) { resp.Success(c, notify.GetChannels()) } // getNotifyChannelConfig 获取渠道配置 // @Summary 获取渠道配置 // @Tags 通知 // @Accept json // @Produce json // @Security BearerAuth // @Param channel query string false "渠道" // @Success 200 {object} resp.ResponseStruct{data=map[string][]notify.Desc} "获取成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/notify/channel/config [get] func getNotifyChannelConfig(c *gin.Context) { channel := c.Query("channel") if channel == "" { resp.Success(c, notify.GetInfoMap()) } else { resp.Success(c, notify.GetInfoMap()[channel]) } } // getNotifyList 获取通知 // @Summary 获取通知 // @Tags 通知 // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} resp.ResponseStruct{data=[]notifyModel.Response} "获取成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/notify [get] func getNotifyList(c *gin.Context) { notifyList, err := op.GetNotifyList() if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } resp.Success(c, notifyList) } // getNotifyNameAndID 获取通知名称和ID // @Summary 获取通知名称和ID // @Tags 通知 // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} resp.ResponseStruct{data=[]notifyModel.NameAndID} "获取成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/notify/name [get] func getNotifyNameAndID(c *gin.Context) { notifyList, err := op.GetNotifyList() if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } nameAndIDList := make([]notifyModel.NameAndID, len(notifyList)) for i, notify := range notifyList { nameAndIDList[i] = notifyModel.NameAndID{ ID: notify.ID, Name: notify.Name, } } resp.Success(c, nameAndIDList) } // createNotify 创建通知 // @Summary 创建通知 // @Description 创建单个通知 // @Tags 通知 // @Accept json // @Produce json // @Security BearerAuth // @Param request body notifyModel.Request true "创建通知请求" // @Success 200 {object} resp.ResponseStruct{data=notifyModel.Response} "创建成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/notify [post] func createNotify(c *gin.Context) { var req notifyModel.Request if err := c.ShouldBindJSON(&req); err != nil { resp.ErrorBadRequest(c) return } notifyData := req.GenData(0) types := notify.GetChannels() if !slices.Contains(types, req.Type) { resp.Error(c, http.StatusBadRequest, fmt.Sprintf("通知类型 %s 不存在", req.Type)) return } if err := op.CreateNotify(c.Request.Context(), ¬ifyData); err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } log.Infof("Notify config %d created by from %s", notifyData.ID, c.ClientIP()) resp.Success(c, notifyData.GenResponse()) } // testNotify 测试通知 // @Summary 测试通知 // @Description 测试单个通知 // @Tags 通知 // @Accept json // @Produce json // @Security BearerAuth // @Param request body notifyModel.Request true "测试通知请求" // @Success 200 {object} resp.ResponseStruct "测试成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/notify/test [post] func testNotify(c *gin.Context) { var req notifyModel.Request if err := c.ShouldBindJSON(&req); err != nil { resp.ErrorBadRequest(c) return } types := notify.GetChannels() if !slices.Contains(types, req.Type) { resp.Error(c, http.StatusBadRequest, fmt.Sprintf("通知类型 %s 不存在", req.Type)) return } notifyData := req.GenData(0) notify, err := notify.Get(notifyData.Type, notifyData.Config) if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } err = notify.Init() if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } var buf bytes.Buffer buf.WriteString("test") err = notify.Send("test", &buf) if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } log.Infof("Notify config %s tested by from %s", notifyData.Type, c.ClientIP()) resp.Success(c, nil) } // updateNotify 更新通知 // @Summary 更新通知 // @Description 根据请求体中的ID更新通知信息 // @Tags 通知 // @Accept json // @Produce json // @Security BearerAuth // @Param id query int true "通知ID" // @Param request body notifyModel.Request true "更新通知请求" // @Success 200 {object} resp.ResponseStruct{data=notifyModel.Response} "更新成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 404 {object} resp.ResponseStruct "通知配置不存在" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/notify [put] func updateNotify(c *gin.Context) { var req notifyModel.Request if err := c.ShouldBindJSON(&req); err != nil { resp.ErrorBadRequest(c) return } idParam := c.Query("id") if idParam == "" { resp.ErrorBadRequest(c) return } id, err := strconv.ParseUint(idParam, 10, 16) if err != nil { resp.ErrorBadRequest(c) return } notifyData := req.GenData(uint16(id)) if err := op.UpdateNotify(c.Request.Context(), ¬ifyData); err != nil { log.Errorf("Update notify config %d failed: %v", id, err) resp.Error(c, http.StatusInternalServerError, "update notify config failed") return } log.Infof("Notify config %d updated by from %s", id, c.ClientIP()) resp.Success(c, notifyData.GenResponse()) } // deleteNotify 删除通知 // @Summary 删除通知 // @Description 根据ID删除单个通知 // @Tags 通知 // @Accept json // @Produce json // @Security BearerAuth // @Param id query int true "通知ID" // @Success 200 {object} resp.ResponseStruct "删除成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 404 {object} resp.ResponseStruct "通知不存在" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/notify [delete] func deleteNotify(c *gin.Context) { idParam := c.Query("id") if idParam == "" { resp.ErrorBadRequest(c) return } id, err := strconv.ParseUint(idParam, 10, 16) if err != nil { resp.ErrorBadRequest(c) return } if err := op.DeleteNotify(c.Request.Context(), uint16(id)); err != nil { log.Errorf("Delete notify config %d failed: %v", id, err) resp.Error(c, http.StatusInternalServerError, "delete notify config failed") return } log.Infof("Notify config %d deleted by from %s", id, c.ClientIP()) resp.Success(c, nil) } // getTemplates 获取通知模板 // @Summary 获取通知模板 // @Tags 通知 // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} resp.ResponseStruct{data=[]notifyModel.Template} "获取成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/notify/template [get] func getTemplates(c *gin.Context) { notifyTemplateList, err := op.GetNotifyTemplateList() if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } resp.Success(c, notifyTemplateList) } // updateTemplate 更新通知模板 // @Summary 更新通知模板 // @Description 根据请求体中的ID更新通知模板信息 // @Tags 通知 // @Accept json // @Produce json // @Security BearerAuth // @Param request body notifyModel.Template true "更新通知模板请求" // @Success 200 {object} resp.ResponseStruct{data=notifyModel.Template} "更新成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 404 {object} resp.ResponseStruct "通知模板不存在" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/notify/template [put] func updateTemplate(c *gin.Context) { var req notifyModel.Template if err := c.ShouldBindJSON(&req); err != nil { resp.Error(c, http.StatusBadRequest, err.Error()) return } if err := op.UpdateNotifyTemplate(c.Request.Context(), &req); err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } log.Infof("Notify template %s updated by from %s", req.Type, c.ClientIP()) resp.Success(c, req) } ================================================ FILE: internal/server/handlers/pprof.go ================================================ //go:build debug package handlers import ( "net/http/pprof" "github.com/bestruirui/bestsub/internal/server/router" "github.com/gin-gonic/gin" ) func init() { router.NewGroupRouter("/debug/pprof"). AddRoute( router.NewRoute("/", router.GET). Handle(index), ). AddRoute( router.NewRoute("/cmdline", router.GET). Handle(cmdline), ). AddRoute( router.NewRoute("/profile", router.GET). Handle(profile), ). AddRoute( router.NewRoute("/symbol", router.GET). Handle(symbol), ). AddRoute( router.NewRoute("/symbol", router.POST). Handle(symbol), ). AddRoute( router.NewRoute("/trace", router.GET). Handle(trace), ). AddRoute( router.NewRoute("/allocs", router.GET). Handle(allocs), ). AddRoute( router.NewRoute("/block", router.GET). Handle(block), ). AddRoute( router.NewRoute("/goroutine", router.GET). Handle(goroutine), ). AddRoute( router.NewRoute("/heap", router.GET). Handle(heap), ). AddRoute( router.NewRoute("/mutex", router.GET). Handle(mutex), ). AddRoute( router.NewRoute("/threadcreate", router.GET). Handle(threadcreate), ) } func index(c *gin.Context) { pprof.Index(c.Writer, c.Request) } func cmdline(c *gin.Context) { pprof.Cmdline(c.Writer, c.Request) } func profile(c *gin.Context) { pprof.Profile(c.Writer, c.Request) } func symbol(c *gin.Context) { pprof.Symbol(c.Writer, c.Request) } func trace(c *gin.Context) { pprof.Trace(c.Writer, c.Request) } func allocs(c *gin.Context) { pprof.Handler("allocs").ServeHTTP(c.Writer, c.Request) } func block(c *gin.Context) { pprof.Handler("block").ServeHTTP(c.Writer, c.Request) } func goroutine(c *gin.Context) { pprof.Handler("goroutine").ServeHTTP(c.Writer, c.Request) } func heap(c *gin.Context) { pprof.Handler("heap").ServeHTTP(c.Writer, c.Request) } func mutex(c *gin.Context) { pprof.Handler("mutex").ServeHTTP(c.Writer, c.Request) } func threadcreate(c *gin.Context) { pprof.Handler("threadcreate").ServeHTTP(c.Writer, c.Request) } ================================================ FILE: internal/server/handlers/scalar.go ================================================ //go:build dev package handlers import ( "net/http" "github.com/bestruirui/bestsub/internal/server/router" "github.com/gin-gonic/gin" ) func init() { router.NewGroupRouter("/scalar"). AddRoute( router.NewRoute("/", router.GET). Handle(scalar), ). AddRoute( router.NewRoute("/api.json", router.GET). Handle(apidata), ) } var scalarHTML = []byte(` BestSub API
`) func scalar(c *gin.Context) { c.Data(http.StatusOK, "text/html; charset=utf-8", scalarHTML) } func apidata(c *gin.Context) { c.File("docs/api/swagger.json") } ================================================ FILE: internal/server/handlers/setting.go ================================================ package handlers import ( "context" "net/http" "github.com/bestruirui/bestsub/internal/database/op" "github.com/bestruirui/bestsub/internal/models/setting" "github.com/bestruirui/bestsub/internal/server/middleware" "github.com/bestruirui/bestsub/internal/server/resp" "github.com/bestruirui/bestsub/internal/server/router" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/gin-gonic/gin" ) func init() { router.NewGroupRouter("/api/v1/setting"). Use(middleware.Auth()). AddRoute( router.NewRoute("", router.GET). Handle(getSetting), ). AddRoute( router.NewRoute("", router.PUT). Handle(updateSetting), ) } // getSetting 获取配置项 // @Summary 获取配置项 // @Description 获取系统所有配置项,支持按分组过滤和关键字搜索 // @Tags 配置 // @Accept json // @Produce json // @Security BearerAuth // @Param group query string false "分组名称" // @Success 200 {object} resp.ResponseStruct{data=[]setting.Setting} "获取成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/setting [get] func getSetting(c *gin.Context) { result, err := op.GetAllSetting(context.Background()) if err != nil { log.Errorf("Failed to get all setting: %v", err) resp.Error(c, http.StatusInternalServerError, "failed to get all setting") return } resp.Success(c, result) } // updateSetting 更新配置项 // @Summary 更新配置项 // @Description 根据请求数据中的ID批量更新配置项的值和描述 // @Tags 配置 // @Accept json // @Produce json // @Security BearerAuth // @Param request body []setting.Setting true "更新配置项请求" // @Success 200 {object} resp.ResponseStruct "更新成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/setting [put] func updateSetting(c *gin.Context) { var req []setting.Setting if err := c.ShouldBindJSON(&req); err != nil { resp.ErrorBadRequest(c) return } err := op.UpdateSetting(context.Background(), &req) if err != nil { log.Errorf("Failed to update config: %v", err) resp.Error(c, http.StatusInternalServerError, "failed to update config") return } resp.Success(c, nil) } ================================================ FILE: internal/server/handlers/share.go ================================================ package handlers import ( "net/http" "strconv" "time" "github.com/bestruirui/bestsub/internal/database/op" shareModel "github.com/bestruirui/bestsub/internal/models/share" "github.com/bestruirui/bestsub/internal/modules/share" "github.com/bestruirui/bestsub/internal/server/middleware" "github.com/bestruirui/bestsub/internal/server/resp" "github.com/bestruirui/bestsub/internal/server/router" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/gin-gonic/gin" ) func init() { router.NewGroupRouter("/api/v1/share"). Use(middleware.Auth()). AddRoute( router.NewRoute("", router.POST). Handle(createShare), ). AddRoute( router.NewRoute("", router.GET). Handle(getShare), ). AddRoute( router.NewRoute("/:id", router.PUT). Handle(updateShare), ). AddRoute( router.NewRoute("/:id", router.DELETE). Handle(deleteShare), ) router.NewGroupRouter("/api/v1/share"). AddRoute( router.NewRoute("/node/:token", router.GET). Handle(getShareNodeContent), ). AddRoute( router.NewRoute("/sub/:token", router.GET). Handle(getShareSubContent), ) } // @Summary 创建分享链接 // @Description 创建分享链接 // @Tags 分享 // @Accept json // @Produce json // @Security BearerAuth // @Param data body shareModel.Request true "分享数据" // @Success 200 {object} resp.ResponseStruct{data=shareModel.Response} "创建成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/share [post] func createShare(c *gin.Context) { var req shareModel.Request if err := c.ShouldBindJSON(&req); err != nil { log.Errorf("createShare: %v", err) resp.ErrorBadRequest(c) return } data := req.GenData() if err := op.CreateShare(c.Request.Context(), &data); err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } resp.Success(c, data.GenResponse()) } // @Summary 获取分享链接 // @Description 获取分享链接 // @Tags 分享 // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} resp.ResponseStruct{data=[]shareModel.Response} "获取成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/share [get] func getShare(c *gin.Context) { shares, err := op.GetShareList(c.Request.Context()) if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } var result = make([]shareModel.Response, 0, len(shares)) for _, v := range shares { result = append(result, v.GenResponse()) } resp.Success(c, result) } // @Summary 更新分享链接 // @Description 更新分享链接 // @Tags 分享 // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "分享ID" // @Param data body shareModel.Request true "分享数据" // @Success 200 {object} resp.ResponseStruct{data=shareModel.Response} "更新成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/share/{id} [put] func updateShare(c *gin.Context) { var req shareModel.Request if err := c.ShouldBindJSON(&req); err != nil { resp.ErrorBadRequest(c) return } id := c.Param("id") idUint, err := strconv.ParseUint(id, 10, 16) if err != nil { resp.ErrorBadRequest(c) return } data := req.GenData() data.ID = uint16(idUint) if err := op.UpdateShare(c.Request.Context(), &data); err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } resp.Success(c, data.GenResponse()) } // @Summary 删除分享链接 // @Description 删除分享链接 // @Tags 分享 // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "分享ID" // @Success 200 {object} resp.ResponseStruct "删除成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/share/{id} [delete] func deleteShare(c *gin.Context) { id := c.Param("id") idUint, err := strconv.ParseUint(id, 10, 16) if err != nil { resp.ErrorBadRequest(c) return } if err := op.DeleteShare(c.Request.Context(), uint16(idUint)); err != nil { resp.ErrorBadRequest(c) return } resp.Success(c, nil) } // @Summary 获取订阅内容 纯Mihomo格式的节点 // @Description 获取订阅内容 纯Mihomo格式的节点 // @Tags 分享 // @Accept json // @Produce plain // @Param token path string true "分享token" // @Success 200 {string} string "获取成功,内容为yaml/plain格式" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/share/node/{token} [get] func getShareNodeContent(c *gin.Context) { token := c.Param("token") clientIp := c.ClientIP() if token == "" { resp.Error(c, http.StatusInternalServerError, "token is required") return } shareData, err := op.GetShareByToken(c.Request.Context(), token) if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } if !shareData.Enable { resp.Error(c, http.StatusInternalServerError, "share not enable") return } if shareData.Expires < uint64(time.Now().Unix()) && shareData.Expires > 0 { resp.Error(c, http.StatusInternalServerError, "share expired") return } if shareData.MaxAccessCount > 0 && shareData.MaxAccessCount <= shareData.AccessCount { resp.Error(c, http.StatusInternalServerError, "share access count exceeded") return } if clientIp != "127.0.0.1" { op.UpdateShareAccessCount(c.Request.Context(), shareData.ID) } c.Data(http.StatusOK, "text/plain; charset=utf-8", share.GenNodeData(shareData.Gen)) } // @Summary 获取订阅内容 带规则的订阅 // @Description 获取订阅内容 带规则的订阅 // @Tags 分享 // @Accept json // @Produce plain // @Param token path string true "分享token" // @Success 200 {string} string "获取成功,内容为yaml/plain格式" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/share/sub/{token} [get] func getShareSubContent(c *gin.Context) { token := c.Param("token") if token == "" { resp.Error(c, http.StatusInternalServerError, "token is required") return } shareData, err := op.GetShareByToken(c.Request.Context(), token) if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } if !shareData.Enable { resp.Error(c, http.StatusInternalServerError, "share not enable") return } if shareData.Expires < uint64(time.Now().Unix()) && shareData.Expires > 0 { resp.Error(c, http.StatusInternalServerError, "share expired") return } if shareData.MaxAccessCount > 0 && shareData.MaxAccessCount <= shareData.AccessCount { resp.Error(c, http.StatusInternalServerError, "share access count exceeded") return } op.UpdateShareAccessCount(c.Request.Context(), shareData.ID) c.Data(http.StatusOK, "text/plain; charset=utf-8", share.GenSubData(shareData.Gen)) } ================================================ FILE: internal/server/handlers/storage.go ================================================ package handlers import ( "net/http" "strconv" "github.com/bestruirui/bestsub/internal/database/op" storageModel "github.com/bestruirui/bestsub/internal/models/storage" "github.com/bestruirui/bestsub/internal/modules/storage" "github.com/bestruirui/bestsub/internal/server/middleware" "github.com/bestruirui/bestsub/internal/server/resp" "github.com/bestruirui/bestsub/internal/server/router" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/gin-gonic/gin" ) func init() { router.NewGroupRouter("/api/v1/storage"). Use(middleware.Auth()). AddRoute( router.NewRoute("", router.POST). Handle(createStorage), ). AddRoute( router.NewRoute("", router.GET). Handle(getStorage), ). AddRoute( router.NewRoute("/:id", router.PUT). Handle(updateStorage), ). AddRoute( router.NewRoute("/:id", router.DELETE). Handle(deleteStorage), ). AddRoute( router.NewRoute("/channel", router.GET). Handle(getStorageChannel), ). AddRoute( router.NewRoute("/channel/config", router.GET). Handle(getStorageChannelConfig), ) } // @Summary 创建存储 // @Description 创建存储 // @Tags 存储 // @Accept json // @Produce json // @Security BearerAuth // @Param data body storageModel.Request true "存储配置数据" // @Success 200 {object} resp.ResponseStruct{data=storageModel.Response} "创建成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/storage [post] func createStorage(c *gin.Context) { var req storageModel.Request if err := c.ShouldBindJSON(&req); err != nil { log.Errorf("createStorage: %v", err) resp.ErrorBadRequest(c) return } data := req.GenData(0) if err := op.CreateStorage(c.Request.Context(), &data); err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } resp.Success(c, data.GenResponse()) } // @Summary 获取存储 // @Description 获取存储 // @Tags 存储 // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} resp.ResponseStruct{data=[]storageModel.Response} "获取成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/storage [get] func getStorage(c *gin.Context) { storages, err := op.GetStorageList(c.Request.Context()) if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } result := make([]storageModel.Response, len(storages)) for i, v := range storages { result[i] = v.GenResponse() } resp.Success(c, result) } // getStorageChannel 获取存储渠道 // @Summary 获取存储渠道 // @Tags 存储 // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} resp.ResponseStruct{data=[]string} "获取成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/storage/channel [get] func getStorageChannel(c *gin.Context) { channels := make([]string, 0, len(storage.GetInfoMap())) for channel := range storage.GetInfoMap() { channels = append(channels, channel) } resp.Success(c, channels) } // getStorageChannelConfig 获取渠道配置 // @Summary 获取渠道配置 // @Tags 存储 // @Accept json // @Produce json // @Security BearerAuth // @Param channel query string false "渠道" // @Success 200 {object} resp.ResponseStruct{data=map[string][]storage.Desc} "获取成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/storage/channel/config [get] func getStorageChannelConfig(c *gin.Context) { channel := c.Query("channel") if channel == "" { resp.Success(c, storage.GetInfoMap()) } else { resp.Success(c, storage.GetInfoMap()[channel]) } } // @Summary 更新存储 // @Description 更新存储 // @Tags 存储 // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "存储ID" // @Param data body storageModel.Request true "存储配置数据" // @Success 200 {object} resp.ResponseStruct{data=storageModel.Response} "更新成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/storage/{id} [put] func updateStorage(c *gin.Context) { idStr := c.Param("id") idUint, err := strconv.ParseUint(idStr, 10, 16) if err != nil { resp.ErrorBadRequest(c) return } var req storageModel.Request if err := c.ShouldBindJSON(&req); err != nil { resp.ErrorBadRequest(c) return } data := req.GenData(uint16(idUint)) if err := op.UpdateStorage(c.Request.Context(), &data); err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } resp.Success(c, data.GenResponse()) } // @Summary 删除存储 // @Description 删除存储 // @Tags 存储 // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "存储ID" // @Success 200 {object} resp.ResponseStruct "删除成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/storage/{id} [delete] func deleteStorage(c *gin.Context) { id := c.Param("id") idUint, err := strconv.ParseUint(id, 10, 16) if err != nil { resp.ErrorBadRequest(c) return } if err := op.DeleteStorage(c.Request.Context(), uint16(idUint)); err != nil { resp.ErrorBadRequest(c) return } resp.Success(c, nil) } ================================================ FILE: internal/server/handlers/sub.go ================================================ package handlers import ( "net/http" "strconv" "github.com/bestruirui/bestsub/internal/core/cron" "github.com/bestruirui/bestsub/internal/core/node" "github.com/bestruirui/bestsub/internal/database/op" "github.com/bestruirui/bestsub/internal/models/sub" "github.com/bestruirui/bestsub/internal/server/middleware" "github.com/bestruirui/bestsub/internal/server/resp" "github.com/bestruirui/bestsub/internal/server/router" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/gin-gonic/gin" ) func init() { router.NewGroupRouter("/api/v1/sub"). Use(middleware.Auth()). AddRoute( router.NewRoute("", router.POST). Handle(createSub), ). AddRoute( router.NewRoute("", router.GET). Handle(getSubs), ). AddRoute( router.NewRoute("/:id", router.PUT). Handle(updateSub), ). AddRoute( router.NewRoute("/:id", router.DELETE). Handle(deleteSub), ). AddRoute( router.NewRoute("/refresh/:id", router.POST). Handle(refreshSub), ). AddRoute( router.NewRoute("/batch", router.POST). Handle(batchCreateSub), ) } // createSub 创建订阅链接 // @Summary 创建订阅链接 // @Description 创建单个订阅链接 // @Tags 订阅 // @Accept json // @Produce json // @Security BearerAuth // @Param request body sub.Request true "创建订阅链接请求" // @Success 200 {object} resp.ResponseStruct{data=sub.Response} "创建成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/sub [post] func createSub(c *gin.Context) { var req sub.Request if err := c.ShouldBindJSON(&req); err != nil { resp.ErrorBadRequest(c) return } subData := req.GenData(0) if err := op.CreateSub(c.Request.Context(), &subData); err != nil { log.Errorf("failed to create sub: %v", err) resp.Error(c, http.StatusInternalServerError, err.Error()) return } cron.FetchAdd(&subData) respData := subData.GenResponse(cron.FetchStatus(subData.ID), node.GetSubInfo(subData.ID)) resp.Success(c, respData) } // getSubs 获取订阅链接 // @Summary 获取订阅链接 // @Tags 订阅 // @Accept json // @Produce json // @Security BearerAuth // @Param id query int true "链接ID" // @Success 200 {object} resp.ResponseStruct{data=[]sub.Response} "获取成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/sub [get] func getSubs(c *gin.Context) { idStr := c.Query("id") if idStr == "" { subList, err := op.GetSubList(c.Request.Context()) if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } var respSubList = make([]sub.Response, len(subList)) for i := range subList { respSubList[i] = subList[i].GenResponse(cron.FetchStatus(subList[i].ID), node.GetSubInfo(subList[i].ID)) } resp.Success(c, respSubList) } else { id, err := strconv.ParseUint(idStr, 10, 16) if err != nil { resp.ErrorBadRequest(c) return } subData, err := op.GetSubByID(c.Request.Context(), uint16(id)) if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } var respSub = [1]sub.Response{} respSub[0] = subData.GenResponse(cron.FetchStatus(subData.ID), node.GetSubInfo(subData.ID)) resp.Success(c, respSub) } } // updateSub 更新订阅链接 // @Summary 更新订阅链接 // @Description 根据请求体中的ID更新订阅链接信息 // @Tags 订阅 // @Accept json // @Produce json // @Security BearerAuth // @Param id path int true "订阅链接ID" // @Param request body sub.Request true "更新订阅链接请求" // @Success 200 {object} resp.ResponseStruct{data=sub.Response} "更新成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 404 {object} resp.ResponseStruct "订阅链接不存在" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/sub/{id} [put] func updateSub(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 16) if err != nil { resp.ErrorBadRequest(c) return } var req sub.Request if err := c.ShouldBindJSON(&req); err != nil { resp.ErrorBadRequest(c) return } subData := req.GenData(uint16(id)) if err := op.UpdateSub(c.Request.Context(), &subData); err != nil { log.Errorf("failed to update sub: %v", err) resp.Error(c, http.StatusInternalServerError, err.Error()) return } if err := cron.FetchUpdate(&subData); err != nil { log.Errorf("failed to update sub: %v", err) resp.Error(c, http.StatusInternalServerError, err.Error()) return } respData := subData.GenResponse(cron.FetchStatus(subData.ID), node.GetSubInfo(subData.ID)) resp.Success(c, respData) } // deleteSub 删除订阅链接 // @Summary 删除订阅链接 // @Description 根据ID删除单个订阅链接 // @Tags 订阅 // @Accept json // @Produce json // @Security BearerAuth // @Param id path int true "订阅链接ID" // @Success 200 {object} resp.ResponseStruct "删除成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 404 {object} resp.ResponseStruct "订阅链接不存在" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/sub/{id} [delete] func deleteSub(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 16) if err != nil { resp.ErrorBadRequest(c) return } if err := op.DeleteSub(c.Request.Context(), uint16(id)); err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } if err := cron.FetchRemove(uint16(id)); err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } node.DeleteBySubId(uint16(id)) resp.Success(c, nil) } // refreshSub 手动刷新订阅 // @Summary 手动刷新订阅 // @Description 根据ID手动刷新单个订阅 // @Tags 订阅 // @Accept json // @Produce json // @Security BearerAuth // @Param id path int true "订阅链接ID" // @Success 200 {object} resp.ResponseStruct{data=sub.Result} "刷新成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 404 {object} resp.ResponseStruct "订阅链接不存在" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/sub/refresh/{id} [post] func refreshSub(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 16) if err != nil { resp.ErrorBadRequest(c) return } result := cron.FetchRun(uint16(id)) resp.Success(c, result) } // batchCreateSub 批量创建订阅链接 // @Summary 批量创建订阅链接 // @Description 批量创建多个订阅链接 // @Tags 订阅 // @Accept json // @Produce json // @Security BearerAuth // @Param request body []sub.Request true "批量创建订阅链接请求" // @Success 200 {object} resp.ResponseStruct{data=[]sub.Response} "创建成功" // @Failure 400 {object} resp.ResponseStruct "请求参数错误" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/sub/batch [post] func batchCreateSub(c *gin.Context) { var reqs []sub.Request if err := c.ShouldBindJSON(&reqs); err != nil { resp.ErrorBadRequest(c) return } if len(reqs) == 0 { resp.ErrorBadRequest(c) return } subs := make([]*sub.Data, len(reqs)) for i, req := range reqs { subData := req.GenData(0) subs[i] = &subData } if err := op.BatchCreateSub(c.Request.Context(), subs); err != nil { log.Errorf("failed to batch create subs: %v", err) resp.Error(c, http.StatusInternalServerError, err.Error()) return } for _, subData := range subs { cron.FetchAdd(subData) } respData := make([]sub.Response, len(subs)) for i, subData := range subs { respData[i] = subData.GenResponse(cron.FetchStatus(subData.ID), node.GetSubInfo(subData.ID)) } resp.Success(c, respData) } ================================================ FILE: internal/server/handlers/update.go ================================================ package handlers import ( "net/http" "github.com/bestruirui/bestsub/internal/core/update" "github.com/bestruirui/bestsub/internal/server/middleware" "github.com/bestruirui/bestsub/internal/server/resp" "github.com/bestruirui/bestsub/internal/server/router" "github.com/gin-gonic/gin" ) func init() { router.NewGroupRouter("/api/v1/update"). Use(middleware.Auth()). AddRoute( router.NewRoute("", router.GET). Handle(latest), ). AddRoute( router.NewRoute("/:name", router.POST). Handle(updateFunc), ) } // latest 最新版本 // @Summary 最新版本 // @Description 获取程序最新版本信息 // @Tags 更新 // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} resp.ResponseStruct{data=map[string]update.LatestInfo} "获取成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/update [get] func latest(c *gin.Context) { latestInfo := make(map[string]update.LatestInfo, 1) bestsub, err := update.GetLatestBestsubInfo() if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } latestInfo["bestsub"] = *bestsub resp.Success(c, latestInfo) } // update 更新 // @Summary 更新 // @Description 更新程序 // @Tags 更新 // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} resp.ResponseStruct{data=string} "获取成功" // @Failure 401 {object} resp.ResponseStruct "未授权" // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" // @Router /api/v1/update/:name [post] func updateFunc(c *gin.Context) { name := c.Param("name") switch name { case "bestsub": err := update.UpdateCore() if err != nil { resp.Error(c, http.StatusInternalServerError, err.Error()) return } default: resp.ErrorBadRequest(c) } resp.Success(c, nil) } ================================================ FILE: internal/server/handlers/ws.go ================================================ package handlers import ( "net/http" "strings" "sync" "sync/atomic" "time" "github.com/bestruirui/bestsub/internal/server/middleware" "github.com/bestruirui/bestsub/internal/server/resp" "github.com/bestruirui/bestsub/internal/server/router" "github.com/bestruirui/bestsub/internal/utils" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" ) // 日志等级优先级常量 var logLevelPriority = map[string]int{ "debug": 0, "info": 1, "warn": 2, "error": 3, "fatal": 4, } // 默认配置 const ( WriteTimeout = 5 // 10秒 PingInterval = 5 // 5秒 MaxConnections = 20 // 20个连接 WriteBufferSize = 1024 ChannelBufferSize = 256 ) // LogFilter 日志过滤器 type LogFilter struct { nameFilter string levelFilter string } // ShouldSend 检查是否应该发送日志 func (f *LogFilter) ShouldSend(logEntry log.LogEntry) bool { if f.nameFilter != "" && !strings.Contains(logEntry.Name, f.nameFilter) { return false } if f.levelFilter != "" && !shouldSendLogLevel(f.levelFilter, logEntry.Level) { return false } return true } // shouldSendLogLevel 检查日志等级是否应该发送 func shouldSendLogLevel(filterLevel, logLevel string) bool { filterPriority, filterExists := logLevelPriority[filterLevel] logPriority, logExists := logLevelPriority[logLevel] if !filterExists || !logExists { return true } return logPriority >= filterPriority } // wsHandler WebSocket处理器 type wsHandler struct { upgrader websocket.Upgrader clients map[*websocket.Conn]*Client mu sync.RWMutex clientCount int32 } // Client WebSocket客户端信息 type Client struct { conn *websocket.Conn filter LogFilter send chan log.LogEntry mu sync.RWMutex } // init 函数用于自动注册路由 func init() { wsHandler := newWSHandler() router.NewGroupRouter("/api/v1/ws"). Use(middleware.WSAuth()). AddRoute( router.NewRoute("/logs", router.GET). Handle(wsHandler.handleLogWebSocket), ) } // newWSHandler 创建WebSocket处理器 func newWSHandler() *wsHandler { h := &wsHandler{ upgrader: websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { if utils.IsDebug() { return true } origin := r.Header.Get("Origin") if origin == "" { log.Debugf("WebSocket客户端连接: 没有Origin头") return true } // TODO: 添加允许的域名列表 log.Debugf("WebSocket客户端连接: Origin=%s", origin) return true }, WriteBufferSize: WriteBufferSize, }, clients: make(map[*websocket.Conn]*Client), } go h.broadcastLogs() return h } func (h *wsHandler) handleLogWebSocket(c *gin.Context) { if atomic.LoadInt32(&h.clientCount) >= MaxConnections { resp.Error(c, http.StatusTooManyRequests, "connection limit reached") return } conn, err := h.upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { log.Errorf("WebSocket升级失败: %v", err) return } nameFilter := c.Query("name") levelFilter := c.Query("level") client := &Client{ conn: conn, filter: LogFilter{ nameFilter: nameFilter, levelFilter: levelFilter, }, send: make(chan log.LogEntry, ChannelBufferSize), } h.mu.Lock() h.clients[conn] = client atomic.AddInt32(&h.clientCount, 1) h.mu.Unlock() username, _ := c.Get("username") clientIP := c.ClientIP() log.Infof("WebSocket客户端连接: 用户=%s, IP=%s, 当前连接数=%d", username, clientIP, atomic.LoadInt32(&h.clientCount)) go h.handleClient(client) } func (h *wsHandler) broadcastLogs() { logChannel := log.GetWSChannel() for logEntry := range logChannel { h.broadcastToClients(logEntry) } } func (h *wsHandler) broadcastToClients(logEntry log.LogEntry) { var clientsToRemove []*websocket.Conn for conn, client := range h.clients { if h.shouldSendLog(client, logEntry) { select { case client.send <- logEntry: default: clientsToRemove = append(clientsToRemove, conn) log.Warnf("WebSocket客户端发送缓冲区满,移除客户端: %v", conn.RemoteAddr()) } } } if len(clientsToRemove) > 0 { h.mu.Lock() for _, conn := range clientsToRemove { if client, exists := h.clients[conn]; exists { close(client.send) delete(h.clients, conn) atomic.AddInt32(&h.clientCount, -1) } } h.mu.Unlock() if len(clientsToRemove) > 0 { log.Warnf("移除了 %d 个缓冲区满的WebSocket客户端", len(clientsToRemove)) } } } func (h *wsHandler) shouldSendLog(client *Client, logEntry log.LogEntry) bool { client.mu.RLock() defer client.mu.RUnlock() return client.filter.ShouldSend(logEntry) } func (h *wsHandler) handleClient(client *Client) { defer func() { h.removeClient(client) client.conn.Close() }() ticker := time.NewTicker(time.Duration(PingInterval) * time.Second) defer ticker.Stop() for { select { case logEntry := <-client.send: client.conn.SetWriteDeadline(time.Now().Add(time.Duration(WriteTimeout) * time.Second)) if err := client.conn.WriteJSON(logEntry); err != nil { log.Errorf("WebSocket发送消息失败: %v", err) return } case <-ticker.C: client.conn.SetWriteDeadline(time.Now().Add(time.Duration(WriteTimeout) * time.Second)) if err := client.conn.WriteMessage(websocket.PingMessage, nil); err != nil { log.Debugf("WebSocket ping失败,断开连接: %v", err) return } } } } func (h *wsHandler) removeClient(client *Client) { h.mu.Lock() defer h.mu.Unlock() if _, exists := h.clients[client.conn]; exists { delete(h.clients, client.conn) close(client.send) atomic.AddInt32(&h.clientCount, -1) log.Debugf("WebSocket客户端断开连接, 当前连接数=%d", atomic.LoadInt32(&h.clientCount)) } } ================================================ FILE: internal/server/middleware/auth.go ================================================ package middleware import ( "net/http" "strings" "github.com/bestruirui/bestsub/internal/config" "github.com/bestruirui/bestsub/internal/server/auth" "github.com/bestruirui/bestsub/internal/server/resp" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/gin-gonic/gin" ) // Auth JWT认证中间件 func Auth() gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") if authHeader == "" { resp.Error(c, http.StatusUnauthorized, "Authorization header is required") c.Abort() return } if !strings.HasPrefix(authHeader, "Bearer ") { resp.Error(c, http.StatusUnauthorized, "Invalid Authorization header") c.Abort() return } token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) if token == "" { resp.Error(c, http.StatusUnauthorized, "Invalid Authorization header") c.Abort() return } claims, err := auth.ValidateToken(token, config.Base().JWT.Secret) if err != nil { log.Warnf("JWT validation failed: %v", err) resp.Error(c, http.StatusUnauthorized, "Invalid or expired token") c.Abort() return } c.Set("username", claims.Username) c.Next() } } // WSAuth WebSocket专用认证中间件 // WebSocket连接的认证处理与普通HTTP请求不同,需要特殊处理 func WSAuth() gin.HandlerFunc { return func(c *gin.Context) { token := c.Query("token") if token == "" { log.Warnf("WebSocket authentication failed: missing token, IP=%s", c.ClientIP()) c.AbortWithStatus(http.StatusUnauthorized) return } claims, err := auth.ValidateToken(token, config.Base().JWT.Secret) if err != nil { log.Warnf("WebSocket JWT validation failed: %v, IP=%s", err, c.ClientIP()) c.AbortWithStatus(http.StatusUnauthorized) return } c.Set("username", claims.Username) c.Next() } } ================================================ FILE: internal/server/middleware/cors.go ================================================ package middleware import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" ) func Cors() gin.HandlerFunc { config := cors.DefaultConfig() config.AllowAllOrigins = true config.AllowCredentials = true config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} config.AllowHeaders = []string{"*"} return cors.New(config) } ================================================ FILE: internal/server/middleware/logging.go ================================================ package middleware import ( "github.com/bestruirui/bestsub/internal/utils/log" "github.com/gin-gonic/gin" ) // 日志中间件 func Logging() gin.HandlerFunc { return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { log.Debugf("%s %d %s %s %s", param.Method, param.StatusCode, param.ClientIP, param.Latency, param.Path, ) return "" }) } ================================================ FILE: internal/server/middleware/recovery.go ================================================ package middleware import ( "net/http" "github.com/bestruirui/bestsub/internal/server/resp" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/gin-gonic/gin" ) func Recovery() gin.HandlerFunc { return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) { log.Warnf("Panic recovered: %v", recovered) resp.Error(c, http.StatusInternalServerError, "An unexpected error occurred") c.Abort() }) } ================================================ FILE: internal/server/middleware/static.go ================================================ package middleware import ( "io/fs" "net/http" "strings" "github.com/gin-gonic/gin" ) func StaticEmbed(urlPrefix string, embedFS fs.FS) gin.HandlerFunc { fs := http.FS(embedFS) return static(urlPrefix, fs) } func StaticLocal(urlPrefix string, localPath string) gin.HandlerFunc { fs := http.Dir(localPath) return static(urlPrefix, fs) } func static(urlPrefix string, fileSystem http.FileSystem) gin.HandlerFunc { fileserver := http.FileServer(fileSystem) if urlPrefix != "" { fileserver = http.StripPrefix(urlPrefix, fileserver) } return func(c *gin.Context) { if strings.HasPrefix(c.Request.URL.Path, "/api") { c.Next() return } if _, err := fileSystem.Open(c.Request.URL.Path); err == nil { c.Header("Cache-Control", "public, max-age=31536000, immutable") fileserver.ServeHTTP(c.Writer, c.Request) c.Abort() } } } ================================================ FILE: internal/server/resp/resp.go ================================================ package resp import ( "net/http" "github.com/gin-gonic/gin" ) type ResponseStruct struct { Code int `json:"code" example:"200"` Message string `json:"message" example:"success"` Data interface{} `json:"data,omitempty"` } type ResponsePaginationStruct struct { Page int `json:"page" example:"1"` PageSize int `json:"page_size" example:"10"` Total uint16 `json:"total" example:"100"` Data interface{} `json:"data"` } func Success(c *gin.Context, data any) { c.JSON(http.StatusOK, ResponseStruct{ Code: http.StatusOK, Message: "success", Data: data, }) } func Error(c *gin.Context, code int, err string) { c.JSON(code, ResponseStruct{ Code: code, Message: err, }) c.Abort() } func ErrorBadRequest(c *gin.Context) { c.JSON(http.StatusBadRequest, ResponseStruct{ Code: http.StatusBadRequest, Message: "bad request", }) c.Abort() } ================================================ FILE: internal/server/router/router.go ================================================ package router import ( "fmt" "strings" "github.com/gin-gonic/gin" ) // Method represents HTTP methods type Method string const ( GET Method = "GET" POST Method = "POST" PUT Method = "PUT" DELETE Method = "DELETE" HEAD Method = "HEAD" OPTIONS Method = "OPTIONS" PATCH Method = "PATCH" ANY Method = "ANY" ) // GroupRouter represents a group of routes with shared path prefix and middlewares type GroupRouter struct { Path string Routes []*Route Middlewares []gin.HandlerFunc } // Global registry for route groups var registeredRouters []*GroupRouter // NewGroupRouter creates a new GroupRouter with the given path and automatically registers it. func NewGroupRouter(path string) *GroupRouter { router := &GroupRouter{ Path: path, Routes: make([]*Route, 0), } registeredRouters = append(registeredRouters, router) return router } // Use adds middlewares to the group. func (g *GroupRouter) Use(middlewares ...gin.HandlerFunc) *GroupRouter { g.Middlewares = append(g.Middlewares, middlewares...) return g } // AddRoute adds a route to the group. func (g *GroupRouter) AddRoute(route *Route) *GroupRouter { g.Routes = append(g.Routes, route) return g } // Route defines a single endpoint with its handlers and middlewares. type Route struct { Path string Method Method Handlers []gin.HandlerFunc Middlewares []gin.HandlerFunc } // NewRoute creates a new Route instance with the given path and method. func NewRoute(path string, method Method) *Route { return &Route{ Path: path, Method: method, Handlers: make([]gin.HandlerFunc, 0), } } // Handle adds handler functions to the route. func (r *Route) Handle(handlers ...gin.HandlerFunc) *Route { r.Handlers = append(r.Handlers, handlers...) return r } // Use adds middlewares to the route. func (r *Route) Use(middlewares ...gin.HandlerFunc) *Route { r.Middlewares = append(r.Middlewares, middlewares...) return r } // Validate checks if the route is valid func (r *Route) Validate() error { if len(r.Handlers) == 0 { return fmt.Errorf("route must have at least one handler") } return nil } // GetRouterCount returns the total count of registered routes func GetRouterCount() int { count := 0 for _, router := range registeredRouters { count += len(router.Routes) } return count } // RegisterAll registers all globally registered route groups to the Gin engine func RegisterAll(engine *gin.Engine) error { for _, router := range registeredRouters { // Validate all routes in the group first for _, route := range router.Routes { if err := route.Validate(); err != nil { return fmt.Errorf("invalid route in group %s: %w", router.Path, err) } } // Create the route group group := engine.Group(router.Path, router.Middlewares...) // Register all routes in the group for _, route := range router.Routes { handlers := make([]gin.HandlerFunc, 0, len(route.Middlewares)+len(route.Handlers)) handlers = append(handlers, route.Middlewares...) handlers = append(handlers, route.Handlers...) registerRoute(group, route.Method, route.Path, handlers) } } registeredRouters = nil return nil } // registerRoute registers a single route to a Gin route group. func registerRoute(group *gin.RouterGroup, method Method, path string, handlers []gin.HandlerFunc) { if len(handlers) == 0 { return } if path != "" { if !strings.HasPrefix(path, "/") { path = "/" + path } } switch method { case GET: group.GET(path, handlers...) case POST: group.POST(path, handlers...) case PUT: group.PUT(path, handlers...) case DELETE: group.DELETE(path, handlers...) case HEAD: group.HEAD(path, handlers...) case OPTIONS: group.OPTIONS(path, handlers...) case PATCH: group.PATCH(path, handlers...) case ANY: group.Any(path, handlers...) default: group.GET(path, handlers...) } } ================================================ FILE: internal/server/server/server.go ================================================ // Package server 提供 BestSub 应用程序的入口点。 // // @title BestSub API // @version 1.0.0 // @description BestSub - API 文档 // @description // @description 这是 BestSub 的 API 文档 // @description // @description ## 认证 // @description 大多数接口需要使用 JWT 令牌进行认证。 // @description 认证时,请在 Authorization 头中包含 JWT 令牌: // @description `Authorization: Bearer ` // @description // @description ## 错误响应 // @description 所有错误响应都遵循统一格式,包含 code、message 和 error 字段。 // @description // @description ## 成功响应 // @description 所有成功响应都遵循统一格式,包含 code、message 和 data 字段。 // // @contact.name BestSub API 支持 // @contact.email support@bestsub.com // // @license.name GPL-3.0 // @license.url https://opensource.org/license/gpl-3-0 // // @securityDefinitions.apikey BearerAuth // @in header // @name Authorization // @description 类型为 "Bearer",后跟空格和 JWT 令牌。 // // @tag.name 认证 // @tag.description 用户认证相关接口 // // @tag.name 系统 // @tag.description 系统状态和健康检查接口 package server import ( "context" "fmt" "net/http" "time" "github.com/bestruirui/bestsub/internal/config" _ "github.com/bestruirui/bestsub/internal/server/handlers" "github.com/bestruirui/bestsub/internal/server/middleware" "github.com/bestruirui/bestsub/internal/server/router" "github.com/bestruirui/bestsub/internal/utils/log" "github.com/bestruirui/bestsub/static" "github.com/gin-gonic/gin" ) const ( defaultReadTimeout = 30 * time.Second defaultWriteTimeout = 30 * time.Second defaultIdleTimeout = 60 * time.Second defaultShutdownTimeout = 30 * time.Second defaultMaxHeaderBytes = 1 << 20 // 1MB ) var server *Server type Server struct { httpServer *http.Server router *gin.Engine } func Initialize() error { r, routerErr := setRouter() if routerErr != nil { return fmt.Errorf("failed to set router: %w", routerErr) } server = &Server{ httpServer: &http.Server{ Addr: fmt.Sprintf("%s:%d", config.Base().Server.Host, config.Base().Server.Port), Handler: r, ReadTimeout: defaultReadTimeout, WriteTimeout: defaultWriteTimeout, IdleTimeout: defaultIdleTimeout, MaxHeaderBytes: defaultMaxHeaderBytes, }, router: r, } return nil } func Start() error { if server == nil { return fmt.Errorf("HTTP server not initialized, please call Initialize() first") } log.Infof("Starting HTTP server %s", server.httpServer.Addr) go func() { if err := server.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Errorf("Failed to start HTTP server: %v", err) } }() return nil } func Close() error { if server == nil { return fmt.Errorf("HTTP server not initialized") } ctx, cancel := context.WithTimeout(context.Background(), defaultShutdownTimeout) defer cancel() if err := server.httpServer.Shutdown(ctx); err != nil { log.Errorf("HTTP server force closed: %v", err) return fmt.Errorf("HTTP server force closed: %w", err) } log.Debug("HTTP server closed") return nil } func IsInitialized() bool { return server != nil } func setRouter() (*gin.Engine, error) { gin.SetMode(gin.ReleaseMode) r := gin.New() // r.Use(middleware.Logging()) r.Use(middleware.Recovery()) r.Use(middleware.Cors()) r.Use(middleware.StaticEmbed("/", static.StaticFS)) if err := router.RegisterAll(r); err != nil { return nil, fmt.Errorf("failed to register routes: %w", err) } log.Debugf("successfully registered %d routes", router.GetRouterCount()) return r, nil } ================================================ FILE: internal/utils/cache/cache.go ================================================ // This implementation is based on and modified from https://github.com/fanjindong/go-cache package cache import ( "fmt" "github.com/cespare/xxhash/v2" ) func keyToString[K comparable](key K) string { return fmt.Sprintf("%v", key) } type Cache[K comparable, V any] interface { Set(k K, v V) Get(k K) (V, bool) GetAll() map[K]V Del(keys ...K) int Exists(keys ...K) bool Len() int Clear() } func New[K comparable, V any](shards int) Cache[K, V] { if shards <= 0 { shards = 1024 } c := &cache[K, V]{ shards: make([]*shard[K, V], shards), shardMask: uint64(shards - 1), } for i := 0; i < shards; i++ { c.shards[i] = &shard[K, V]{hashmap: map[K]V{}} } return c } type cache[K comparable, V any] struct { shards []*shard[K, V] shardMask uint64 } func (c *cache[K, V]) Set(k K, v V) { hashedKey := xxhash.Sum64String(keyToString(k)) shard := c.getShard(hashedKey) shard.set(k, v) } func (c *cache[K, V]) Get(k K) (V, bool) { hashedKey := xxhash.Sum64String(keyToString(k)) shard := c.getShard(hashedKey) return shard.get(k) } func (c *cache[K, V]) GetAll() map[K]V { result := make(map[K]V) for _, shard := range c.shards { shardData := shard.getAll() for k, v := range shardData { result[k] = v } } return result } func (c *cache[K, V]) Del(ks ...K) int { var count int for _, k := range ks { hashedKey := xxhash.Sum64String(keyToString(k)) shard := c.getShard(hashedKey) count += shard.del(k) } return count } func (c *cache[K, V]) Exists(ks ...K) bool { for _, k := range ks { if _, found := c.Get(k); !found { return false } } return true } func (c *cache[K, V]) Len() int { var count int for _, shard := range c.shards { count += shard.len() } return count } func (c *cache[K, V]) getShard(hashedKey uint64) (shard *shard[K, V]) { return c.shards[hashedKey&c.shardMask] } func (c *cache[K, V]) Clear() { for _, s := range c.shards { s.clear() } } ================================================ FILE: internal/utils/cache/shard.go ================================================ package cache import ( "sync" ) type shard[K comparable, V any] struct { hashmap map[K]V lock sync.RWMutex } func (c *shard[K, V]) set(k K, v V) { c.lock.Lock() c.hashmap[k] = v c.lock.Unlock() } func (c *shard[K, V]) get(k K) (V, bool) { c.lock.RLock() item, exist := c.hashmap[k] c.lock.RUnlock() if !exist { var zero V return zero, false } return item, true } func (c *shard[K, V]) del(k K) int { c.lock.Lock() defer c.lock.Unlock() if _, found := c.hashmap[k]; found { delete(c.hashmap, k) return 1 } return 0 } func (c *shard[K, V]) clear() { c.lock.Lock() defer c.lock.Unlock() c.hashmap = map[K]V{} } func (c *shard[K, V]) getAll() map[K]V { c.lock.RLock() defer c.lock.RUnlock() result := make(map[K]V, len(c.hashmap)) for k, v := range c.hashmap { result[k] = v } return result } func (c *shard[K, V]) len() int { c.lock.RLock() defer c.lock.RUnlock() return len(c.hashmap) } ================================================ FILE: internal/utils/color/color.go ================================================ package color // 定义颜色和格式化字符串常量 const ( Reset string = "\033[0m" Red string = "\033[31m" Green string = "\033[32m" Yellow string = "\033[33m" Blue string = "\033[34m" Purple string = "\033[35m" Cyan string = "\033[36m" White string = "\033[37m" Bold string = "\033[1m" Dim string = "\033[2m" ) ================================================ FILE: internal/utils/country/conutry.go ================================================ // 此文件由AI生成。如有错误,请手动修正 // This file was generated with AI assistance // Please correct any errors manually package country type Country struct { NameEn string NameZh string Emoji string } func GetCountry(code string) Country { if code == "" { return Country{} } if names, ok := namesByNumeric[code]; ok { return Country{NameEn: code, NameZh: names, Emoji: emoji(code)} } return Country{} } func emoji(alpha2 string) string { if len(alpha2) != 2 { return "" } b0 := rune(alpha2[0]) b1 := rune(alpha2[1]) if b0 >= 'a' && b0 <= 'z' { b0 -= 32 } if b1 >= 'a' && b1 <= 'z' { b1 -= 32 } if b0 < 'A' || b0 > 'Z' || b1 < 'A' || b1 > 'Z' { return "" } r0 := 0x1F1E6 + (b0 - 'A') r1 := 0x1F1E6 + (b1 - 'A') return string([]rune{r0, r1}) } var namesByNumeric = map[string]string{ "AD": "安道尔", "AE": "阿联酋", "AF": "阿富汗", "AG": "安提瓜和巴布达", "AI": "安圭拉", "AL": "阿尔巴尼亚", "AM": "亚美尼亚", "AO": "安哥拉", "AQ": "南极洲", "AR": "阿根廷", "AS": "美属萨摩亚", "AT": "奥地利", "AU": "澳大利亚", "AW": "阿鲁巴", "AZ": "阿塞拜疆", "BA": "波黑", "BB": "巴巴多斯", "BD": "孟加拉国", "BE": "比利时", "BF": "布基纳法索", "BG": "保加利亚", "BH": "巴林", "BI": "布隆迪", "BJ": "贝宁", "BL": "法属圣巴泰勒米", "BM": "百慕大", "BN": "文莱", "BO": "玻利维亚", "BQ": "荷属加勒比区", "BR": "巴西", "BS": "巴哈马", "BT": "不丹", "BV": "布维岛", "BW": "博茨瓦纳", "BY": "白俄罗斯", "BZ": "伯利兹", "CA": "加拿大", "CC": "科科斯(基林)群岛", "CD": "刚果(金)", "CF": "中非共和国", "CG": "刚果(布)", "CH": "瑞士", "CI": "科特迪瓦", "CK": "库克群岛", "CL": "智利", "CM": "喀麦隆", "CN": "中国", "CO": "哥伦比亚", "CR": "哥斯达黎加", "CU": "古巴", "CV": "佛得角", "CW": "库拉索", "CX": "圣诞岛", "CY": "塞浦路斯", "CZ": "捷克", "DE": "德国", "DJ": "吉布提", "DK": "丹麦", "DM": "多米尼克", "DO": "多米尼加", "DZ": "阿尔及利亚", "EC": "厄瓜多尔", "EE": "爱沙尼亚", "EG": "埃及", "EH": "西撒哈拉", "ER": "厄立特里亚", "ES": "西班牙", "ET": "埃塞俄比亚", "FI": "芬兰", "FJ": "斐济", "FK": "福克兰群岛(马尔维纳斯)", "FM": "密克罗尼西亚联邦", "FO": "法罗群岛", "FR": "法国", "GA": "加蓬", "GB": "英国", "GD": "格林纳达", "GE": "格鲁吉亚", "GF": "法属圭亚那", "GG": "根西", "GH": "加纳", "GI": "直布罗陀", "GL": "格陵兰", "GM": "冈比亚", "GN": "几内亚", "GP": "瓜德罗普", "GQ": "赤道几内亚", "GR": "希腊", "GS": "南乔治亚岛和南桑威奇群岛", "GT": "危地马拉", "GU": "关岛", "GW": "几内亚比绍", "GY": "圭亚那", "HK": "中国香港", "HM": "赫德岛和麦克唐纳群岛", "HN": "洪都拉斯", "HR": "克罗地亚", "HT": "海地", "HU": "匈牙利", "ID": "印度尼西亚", "IE": "爱尔兰", "IL": "以色列", "IM": "马恩岛", "IN": "印度", "IO": "英属印度洋领地", "IQ": "伊拉克", "IR": "伊朗", "IS": "冰岛", "IT": "意大利", "JE": "泽西", "JM": "牙买加", "JO": "约旦", "JP": "日本", "KE": "肯尼亚", "KG": "吉尔吉斯斯坦", "KH": "柬埔寨", "KI": "基里巴斯", "KM": "科摩罗", "KN": "圣基茨和尼维斯", "KP": "朝鲜", "KR": "韩国", "KW": "科威特", "KY": "开曼群岛", "KZ": "哈萨克斯坦", "LA": "老挝", "LB": "黎巴嫩", "LC": "圣卢西亚", "LI": "列支敦士登", "LK": "斯里兰卡", "LR": "利比里亚", "LS": "莱索托", "LT": "立陶宛", "LU": "卢森堡", "LV": "拉脱维亚", "LY": "利比亚", "MA": "摩洛哥", "MC": "摩纳哥", "MD": "摩尔多瓦", "ME": "黑山", "MF": "法属圣马丁", "MG": "马达加斯加", "MH": "马绍尔群岛", "MK": "北马其顿", "ML": "马里", "MM": "缅甸", "MN": "蒙古", "MO": "中国澳门", "MP": "北马里亚纳群岛", "MQ": "马提尼克", "MR": "毛里塔尼亚", "MS": "蒙特塞拉特", "MT": "马耳他", "MU": "毛里求斯", "MV": "马尔代夫", "MW": "马拉维", "MX": "墨西哥", "MY": "马来西亚", "MZ": "莫桑比克", "NA": "纳米比亚", "NC": "新喀里多尼亚", "NE": "尼日尔", "NF": "诺福克岛", "NG": "尼日利亚", "NI": "尼加拉瓜", "NL": "荷兰", "NO": "挪威", "NP": "尼泊尔", "NR": "瑙鲁", "NU": "纽埃", "NZ": "新西兰", "OM": "阿曼", "PA": "巴拿马", "PE": "秘鲁", "PF": "法属波利尼西亚", "PG": "巴布亚新几内亚", "PH": "菲律宾", "PK": "巴基斯坦", "PL": "波兰", "PM": "圣皮埃尔和密克隆", "PN": "皮特凯恩群岛", "PR": "波多黎各", "PS": "巴勒斯坦国", "PT": "葡萄牙", "PW": "帕劳", "PY": "巴拉圭", "QA": "卡塔尔", "RE": "留尼汪", "RO": "罗马尼亚", "RS": "塞尔维亚", "RU": "俄罗斯", "RW": "卢旺达", "SA": "沙特阿拉伯", "SB": "所罗门群岛", "SC": "塞舌尔", "SD": "苏丹", "SE": "瑞典", "SG": "新加坡", "SH": "圣赫勒拿、阿森松和特里斯坦-达库尼亚", "SI": "斯洛文尼亚", "SJ": "斯瓦尔巴和扬马延", "SK": "斯洛伐克", "SL": "塞拉利昂", "SM": "圣马力诺", "SN": "塞内加尔", "SO": "索马里", "SR": "苏里南", "SS": "南苏丹", "ST": "圣多美和普林西比", "SV": "萨尔瓦多", "SX": "荷属圣马丁", "SY": "叙利亚", "SZ": "埃斯瓦蒂尼", "TC": "特克斯和凯科斯群岛", "TD": "乍得", "TF": "法属南部领地", "TG": "多哥", "TH": "泰国", "TJ": "塔吉克斯坦", "TK": "托克劳", "TL": "东帝汶", "TM": "土库曼斯坦", "TN": "突尼斯", "TO": "汤加", "TR": "土耳其", "TT": "特立尼达和多巴哥", "TV": "图瓦卢", "TW": "中国台湾", "TZ": "坦桑尼亚", "UA": "乌克兰", "UG": "乌干达", "UM": "美国本土外小岛屿", "US": "美国", "UY": "乌拉圭", "UZ": "乌兹别克斯坦", "VA": "梵蒂冈", "VC": "圣文森特和格林纳丁斯", "VE": "委内瑞拉", "VG": "英属维尔京群岛", "VI": "美属维尔京群岛", "VN": "越南", "VU": "瓦努阿图", "WF": "瓦利斯和富图纳", "WS": "萨摩亚", "YE": "也门", "YT": "马约特", "ZA": "南非", "ZM": "赞比亚", "ZW": "津巴布韦", } ================================================ FILE: internal/utils/desc/desc.go ================================================ package desc import ( "reflect" ) const ( TypeBoolean = "boolean" TypeNumber = "number" TypeString = "string" TypeSelect = "select" TypeMultiSelect = "multi_select" ) type Data struct { Name string `json:"name,omitempty"` Key string `json:"key,omitempty"` Type string `json:"type,omitempty"` Value string `json:"value,omitempty"` Options string `json:"options,omitempty"` Require bool `json:"require,omitempty"` Desc string `json:"desc,omitempty"` } func Gen(v any) []Data { t := reflect.TypeOf(v) if t.Kind() == reflect.Ptr { t = t.Elem() } return gen(t) } func gen(t reflect.Type) []Data { var items []Data for i := 0; i < t.NumField(); i++ { field := t.Field(i) if field.Type.Kind() == reflect.Struct { items = append(items, gen(field.Type)...) continue } tag := field.Tag key, ok := tag.Lookup("json") if !ok { continue } typeName := tag.Get("type") if typeName == "" { typeName = getType(field.Type.Name()) } item := Data{ Name: tag.Get("name"), Key: key, Type: typeName, Value: tag.Get("value"), Options: tag.Get("options"), Require: tag.Get("require") == "true", Desc: tag.Get("desc"), } items = append(items, item) } return items } func getType(t string) string { switch t { case "bool": return "boolean" case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "float32", "float64": return "number" case "string", "[]byte": return "string" default: return "object" } } ================================================ FILE: internal/utils/generic/map.go ================================================ // This file is based on the generic sync.Map implementation from: // https://github.com/SaveTheRbtz/generic-sync-map-go // Licensed under the MIT License // // Original copyright notice: // Copyright 2016 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package generic import ( "sync" "sync/atomic" "unsafe" ) // MapOf is like a Go map[interface{}]interface{} but is safe for concurrent use // by multiple goroutines without additional locking or coordination. // Loads, stores, and deletes run in amortized constant time. // // The MapOf type is specialized. Most code should use a plain Go map instead, // with separate locking or coordination, for better type safety and to make it // easier to maintain other invariants along with the map content. // // The MapOf type is optimized for two common use cases: (1) when the entry for a given // key is only ever written once but read many times, as in caches that only grow, // or (2) when multiple goroutines read, write, and overwrite entries for disjoint // sets of keys. In these two cases, use of a MapOf may significantly reduce lock // contention compared to a Go map paired with a separate Mutex or RWMutex. // // The zero MapOf is empty and ready for use. A MapOf must not be copied after first use. type MapOf[K comparable, V any] struct { mu sync.Mutex // read contains the portion of the map's contents that are safe for // concurrent access (with or without mu held). // // The read field itself is always safe to load, but must only be stored with // mu held. // // Entries stored in read may be updated concurrently without mu, but updating // a previously-expunged entry requires that the entry be copied to the dirty // map and unexpunged with mu held. read atomic.Value // readOnly // dirty contains the portion of the map's contents that require mu to be // held. To ensure that the dirty map can be promoted to the read map quickly, // it also includes all of the non-expunged entries in the read map. // // Expunged entries are not stored in the dirty map. An expunged entry in the // clean map must be unexpunged and added to the dirty map before a new value // can be stored to it. // // If the dirty map is nil, the next write to the map will initialize it by // making a shallow copy of the clean map, omitting stale entries. dirty map[K]*entry[V] // misses counts the number of loads since the read map was last updated that // needed to lock mu to determine whether the key was present. // // Once enough misses have occurred to cover the cost of copying the dirty // map, the dirty map will be promoted to the read map (in the unamended // state) and the next store to the map will make a new dirty copy. misses int } // readOnly is an immutable struct stored atomically in the MapOf.read field. type readOnly[K comparable, V any] struct { m map[K]*entry[V] amended bool // true if the dirty map contains some key not in m. } // expunged is an arbitrary pointer that marks entries which have been deleted // from the dirty map. var expunged = unsafe.Pointer(new(interface{})) // An entry is a slot in the map corresponding to a particular key. type entry[V any] struct { // p points to the interface{} value stored for the entry. // // If p == nil, the entry has been deleted and m.dirty == nil. // // If p == expunged, the entry has been deleted, m.dirty != nil, and the entry // is missing from m.dirty. // // Otherwise, the entry is valid and recorded in m.read.m[key] and, if m.dirty // != nil, in m.dirty[key]. // // An entry can be deleted by atomic replacement with nil: when m.dirty is // next created, it will atomically replace nil with expunged and leave // m.dirty[key] unset. // // An entry's associated value can be updated by atomic replacement, provided // p != expunged. If p == expunged, an entry's associated value can be updated // only after first setting m.dirty[key] = e so that lookups using the dirty // map find the entry. p unsafe.Pointer // *interface{} } func newEntry[V any](i V) *entry[V] { return &entry[V]{p: unsafe.Pointer(&i)} } // Load returns the value stored in the map for a key, or nil if no // value is present. // The ok result indicates whether value was found in the map. func (m *MapOf[K, V]) Load(key K) (value V, ok bool) { read, _ := m.read.Load().(readOnly[K, V]) e, ok := read.m[key] if !ok && read.amended { m.mu.Lock() // Avoid reporting a spurious miss if m.dirty got promoted while we were // blocked on m.mu. (If further loads of the same key will not miss, it's // not worth copying the dirty map for this key.) read, _ = m.read.Load().(readOnly[K, V]) e, ok = read.m[key] if !ok && read.amended { e, ok = m.dirty[key] // Regardless of whether the entry was present, record a miss: this key // will take the slow path until the dirty map is promoted to the read // map. m.missLocked() } m.mu.Unlock() } if !ok { return value, false } return e.load() } func (e *entry[V]) load() (value V, ok bool) { p := atomic.LoadPointer(&e.p) if p == nil || p == expunged { return value, false } return *(*V)(p), true } // Store sets the value for a key. func (m *MapOf[K, V]) Store(key K, value V) { read, _ := m.read.Load().(readOnly[K, V]) if e, ok := read.m[key]; ok && e.tryStore(&value) { return } m.mu.Lock() read, _ = m.read.Load().(readOnly[K, V]) if e, ok := read.m[key]; ok { if e.unexpungeLocked() { // The entry was previously expunged, which implies that there is a // non-nil dirty map and this entry is not in it. m.dirty[key] = e } e.storeLocked(&value) } else if e, ok := m.dirty[key]; ok { e.storeLocked(&value) } else { if !read.amended { // We're adding the first new key to the dirty map. // Make sure it is allocated and mark the read-only map as incomplete. m.dirtyLocked() m.read.Store(readOnly[K, V]{m: read.m, amended: true}) } m.dirty[key] = newEntry(value) } m.mu.Unlock() } // tryStore stores a value if the entry has not been expunged. // // If the entry is expunged, tryStore returns false and leaves the entry // unchanged. func (e *entry[V]) tryStore(i *V) bool { for { p := atomic.LoadPointer(&e.p) if p == expunged { return false } if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) { return true } } } // unexpungeLocked ensures that the entry is not marked as expunged. // // If the entry was previously expunged, it must be added to the dirty map // before m.mu is unlocked. func (e *entry[V]) unexpungeLocked() (wasExpunged bool) { return atomic.CompareAndSwapPointer(&e.p, expunged, nil) } // storeLocked unconditionally stores a value to the entry. // // The entry must be known not to be expunged. func (e *entry[V]) storeLocked(i *V) { atomic.StorePointer(&e.p, unsafe.Pointer(i)) } // LoadOrStore returns the existing value for the key if present. // Otherwise, it stores and returns the given value. // The loaded result is true if the value was loaded, false if stored. func (m *MapOf[K, V]) LoadOrStore(key K, value V) (actual V, loaded bool) { // Avoid locking if it's a clean hit. read, _ := m.read.Load().(readOnly[K, V]) if e, ok := read.m[key]; ok { actual, loaded, ok := e.tryLoadOrStore(value) if ok { return actual, loaded } } m.mu.Lock() read, _ = m.read.Load().(readOnly[K, V]) if e, ok := read.m[key]; ok { if e.unexpungeLocked() { m.dirty[key] = e } actual, loaded, _ = e.tryLoadOrStore(value) } else if e, ok := m.dirty[key]; ok { actual, loaded, _ = e.tryLoadOrStore(value) m.missLocked() } else { if !read.amended { // We're adding the first new key to the dirty map. // Make sure it is allocated and mark the read-only map as incomplete. m.dirtyLocked() m.read.Store(readOnly[K, V]{m: read.m, amended: true}) } m.dirty[key] = newEntry(value) actual, loaded = value, false } m.mu.Unlock() return actual, loaded } // tryLoadOrStore atomically loads or stores a value if the entry is not // expunged. // // If the entry is expunged, tryLoadOrStore leaves the entry unchanged and // returns with ok==false. func (e *entry[V]) tryLoadOrStore(i V) (actual V, loaded, ok bool) { p := atomic.LoadPointer(&e.p) if p == expunged { return actual, false, false } if p != nil { return *(*V)(p), true, true } // Copy the interface after the first load to make this method more amenable // to escape analysis: if we hit the "load" path or the entry is expunged, we // shouldn'V bother heap-allocating. ic := i for { if atomic.CompareAndSwapPointer(&e.p, nil, unsafe.Pointer(&ic)) { return i, false, true } p = atomic.LoadPointer(&e.p) if p == expunged { return actual, false, false } if p != nil { return *(*V)(p), true, true } } } // Delete deletes the value for a key. func (m *MapOf[K, V]) Delete(key K) { read, _ := m.read.Load().(readOnly[K, V]) e, ok := read.m[key] if !ok && read.amended { m.mu.Lock() read, _ = m.read.Load().(readOnly[K, V]) e, ok = read.m[key] if !ok && read.amended { delete(m.dirty, key) } m.mu.Unlock() } if ok { e.delete() } } func (e *entry[V]) delete() (hadValue bool) { for { p := atomic.LoadPointer(&e.p) if p == nil || p == expunged { return false } if atomic.CompareAndSwapPointer(&e.p, p, nil) { return true } } } // Range calls f sequentially for each key and value present in the map. // If f returns false, range stops the iteration. // // Range does not necessarily correspond to any consistent snapshot of the MapOf's // contents: no key will be visited more than once, but if the value for any key // is stored or deleted concurrently, Range may reflect any mapping for that key // from any point during the Range call. // // Range may be O(N) with the number of elements in the map even if f returns // false after a constant number of calls. func (m *MapOf[K, V]) Range(f func(key K, value V) bool) { // We need to be able to iterate over all of the keys that were already // present at the start of the call to Range. // If read.amended is false, then read.m satisfies that property without // requiring us to hold m.mu for a long time. read, _ := m.read.Load().(readOnly[K, V]) if read.amended { // m.dirty contains keys not in read.m. Fortunately, Range is already O(N) // (assuming the caller does not break out early), so a call to Range // amortizes an entire copy of the map: we can promote the dirty copy // immediately! m.mu.Lock() read, _ = m.read.Load().(readOnly[K, V]) if read.amended { read = readOnly[K, V]{m: m.dirty} m.read.Store(read) m.dirty = nil m.misses = 0 } m.mu.Unlock() } for k, e := range read.m { v, ok := e.load() if !ok { continue } if !f(k, v) { break } } } func (m *MapOf[K, V]) missLocked() { m.misses++ if m.misses < len(m.dirty) { return } m.read.Store(readOnly[K, V]{m: m.dirty}) m.dirty = nil m.misses = 0 } func (m *MapOf[K, V]) dirtyLocked() { if m.dirty != nil { return } read, _ := m.read.Load().(readOnly[K, V]) m.dirty = make(map[K]*entry[V], len(read.m)) for k, e := range read.m { if !e.tryExpungeLocked() { m.dirty[k] = e } } } func (e *entry[V]) tryExpungeLocked() (isExpunged bool) { p := atomic.LoadPointer(&e.p) for p == nil { if atomic.CompareAndSwapPointer(&e.p, nil, expunged) { return true } p = atomic.LoadPointer(&e.p) } return p == expunged } ================================================ FILE: internal/utils/generic/queue.go ================================================ package generic type Integer interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 } type Queue[T Integer] struct { Data []T Ptr int Full bool } func NewQueue[T Integer](capacity int) *Queue[T] { if capacity <= 0 { panic("queue capacity must be positive") } return &Queue[T]{ Data: make([]T, 0, capacity), Ptr: 0, Full: false, } } func (q *Queue[T]) Update(value T) { if q.Full { q.Data[q.Ptr] = value q.Ptr = (q.Ptr + 1) % len(q.Data) } else { q.Data = append(q.Data, value) if len(q.Data) == cap(q.Data) { q.Full = true } } } func (q *Queue[T]) GetAll() []T { if len(q.Data) == 0 { return nil } result := make([]T, len(q.Data)) if q.Full { tailLen := len(q.Data) - q.Ptr copy(result, q.Data[q.Ptr:]) copy(result[tailLen:], q.Data[:q.Ptr]) } else { copy(result, q.Data) } return result } func (q *Queue[T]) Clear() { q.Data = q.Data[:0] q.Ptr = 0 q.Full = false } func (q *Queue[T]) Average() T { if len(q.Data) == 0 { return 0 } var sum int64 for _, value := range q.Data { sum += int64(value) } return T(sum / int64(len(q.Data))) } ================================================ FILE: internal/utils/info/info.go ================================================ package info import ( "fmt" "strings" "time" "github.com/bestruirui/bestsub/internal/utils/color" ) var ( Version = "dev" Commit = "unknown" BuildTime = "unknown" Author = "bestrui" Repo = "https://github.com/bestruirui/bestsub" ) func Banner() { logo := ` ██████╗ ███████╗███████╗████████╗███████╗██╗ ██╗██████╗ ██╔══██╗██╔════╝██╔════╝╚══██╔══╝██╔════╝██║ ██║██╔══██╗ ██████╔╝█████╗ ███████╗ ██║ ███████╗██║ ██║██████╔╝ ██╔══██╗██╔══╝ ╚════██║ ██║ ╚════██║██║ ██║██╔══██╗ ██████╔╝███████╗███████║ ██║ ███████║╚██████╔╝██████╔╝ ╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚══════╝ ╚═════╝ ╚═════╝ ` fmt.Print(color.Cyan + color.Bold) fmt.Println(logo) fmt.Print(color.Reset) fmt.Print(color.Blue + color.Bold) fmt.Println(" 🚀 BestSub - Best Sub For You") fmt.Print(color.Reset) fmt.Print(color.Dim) fmt.Println(" " + strings.Repeat("─", 60)) fmt.Print(color.Reset) printInfo("Version", Version, color.Green) printInfo("Commit", Commit[:min(8, len(Commit))], color.Yellow) printInfo("Build Time", formatDate(BuildTime), color.Blue) printInfo("Built By", Author, color.Purple) printInfo("Repo", Repo, color.Cyan) fmt.Print(color.Dim) fmt.Println(" " + strings.Repeat("═", 60)) fmt.Print(color.Reset) } func printInfo(label, value, print_color string) { fmt.Printf(" %s%-12s%s %s%s%s\n", color.Dim, label+":", color.Reset, print_color, value, color.Reset) } func formatDate(date string) string { if date == "unknown" || date == "" { return "unknown" } layouts := []string{ "2006-01-02T15:04:05Z", "2006-01-02 15:04:05", "2006-01-02", time.RFC3339, } for _, layout := range layouts { if t, err := time.Parse(layout, date); err == nil { return t.Format("2006-01-02 15:04") } } return date } func min(a, b int) int { if a < b { return a } return b } ================================================ FILE: internal/utils/log/log.go ================================================ package log import ( "bufio" "fmt" "io" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "time" "github.com/bestruirui/bestsub/internal/utils" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) type LogEntry struct { Level string `json:"level"` Message string `json:"message"` Name string `json:"-"` } var ( wsChannel chan LogEntry basePath string = "build" useConsole bool useFile bool logger *Logger separator = []byte(",") ) var consoleEncoder = zapcore.EncoderConfig{ TimeKey: "time", LevelKey: "level", MessageKey: "msg", CallerKey: "caller", StacktraceKey: "stacktrace", EncodeLevel: zapcore.CapitalColorLevelEncoder, EncodeTime: zapcore.RFC3339TimeEncoder, EncodeCaller: zapcore.ShortCallerEncoder, } var fileEncoder = zapcore.EncoderConfig{ TimeKey: "time", LevelKey: "level", MessageKey: "msg", EncodeLevel: zapcore.LowercaseLevelEncoder, EncodeTime: zapcore.RFC3339TimeEncoder, } type Logger struct { *zap.SugaredLogger bufferedWriter *zapcore.BufferedWriteSyncer } type Config struct { Level string Path string UseConsole bool UseFile bool Name string CallerSkip int } func webSocketHook(entry zapcore.Entry) error { if wsChannel == nil { return nil } logEntry := LogEntry{ Level: entry.Level.String(), Message: entry.Message, Name: entry.LoggerName, } select { case wsChannel <- logEntry: default: } return nil } func init() { wsChannel = make(chan LogEntry, 1000) logger, _ = NewLogger(Config{ Level: "debug", UseConsole: true, CallerSkip: 1, UseFile: false, Name: "main", }) } func Initialize(level, path, method string) error { logger.Close() basePath = path if _, err := os.Stat(basePath); os.IsNotExist(err) { if err := os.MkdirAll(basePath, 0755); err != nil { return fmt.Errorf("failed to create log directory: %w", err) } } mainPath := filepath.Join(basePath, "main", time.Now().Format("20060102150405")+".log") switch method { case "console": useConsole = true useFile = false case "file": useConsole = false useFile = true case "both": useConsole = true useFile = true default: useConsole = true useFile = false } var err error logger, err = NewLogger(Config{ Level: level, Path: mainPath, UseConsole: useConsole, UseFile: useFile, Name: "main", CallerSkip: 1, }) if err != nil { return err } return nil } func GetDefaultLogger() *Logger { return logger } func NewTaskLogger(name string, taskid uint16, level string, writeFile bool) (*Logger, error) { taskidstr := strconv.FormatUint(uint64(taskid), 10) loggerName := "task_" + name + "_" + taskidstr path := filepath.Join(basePath, name, taskidstr, time.Now().Format("20060102150405")+".log") return NewLogger(Config{ Level: level, Path: path, UseConsole: utils.IsDebug(), UseFile: writeFile, Name: loggerName, CallerSkip: 1, }) } func GetWSChannel() <-chan LogEntry { return wsChannel } func NewLogger(config Config) (*Logger, error) { parsedLevel, err := zapcore.ParseLevel(config.Level) if err != nil { parsedLevel = zapcore.InfoLevel } var cores []zapcore.Core var bufferedWriter *zapcore.BufferedWriteSyncer if config.UseConsole { consoleCore := zapcore.NewCore( zapcore.NewConsoleEncoder(consoleEncoder), zapcore.AddSync(os.Stdout), parsedLevel, ) cores = append(cores, consoleCore) } if config.UseFile && config.Path != "" { file, err := createLogFile(config.Path) if err != nil { return nil, err } bufferedWriter = &zapcore.BufferedWriteSyncer{ WS: zapcore.AddSync(file), } fileCore := zapcore.NewCore( zapcore.NewJSONEncoder(fileEncoder), bufferedWriter, parsedLevel, ) cores = append(cores, fileCore) } wsEncoderConfig := zapcore.EncoderConfig{ LevelKey: "level", MessageKey: "msg", EncodeLevel: zapcore.LowercaseLevelEncoder, } wsCore := zapcore.NewCore( zapcore.NewJSONEncoder(wsEncoderConfig), zapcore.AddSync(io.Discard), zapcore.DebugLevel, ) cores = append(cores, wsCore) core := zapcore.NewTee(cores...) logger := zap.New( core, zap.Hooks(webSocketHook), zap.AddStacktrace(zapcore.ErrorLevel), zap.AddCallerSkip(config.CallerSkip), zap.AddCaller(), ) logger.Named(config.Name) return &Logger{ SugaredLogger: logger.Sugar(), bufferedWriter: bufferedWriter, }, nil } func createLogFile(path string) (*os.File, error) { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return nil, fmt.Errorf("failed to create log directory: %w", err) } file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { return nil, fmt.Errorf("failed to open log file: %w", err) } return file, nil } func (l *Logger) Close() error { l.SugaredLogger.Sync() if l.bufferedWriter != nil { if err := l.bufferedWriter.Sync(); err != nil { fmt.Fprintf(os.Stderr, "failed to flush buffered writer: %v\n", err) } l.bufferedWriter = nil } return nil } func Debug(args ...interface{}) { logger.Debug(args...) } func Info(args ...interface{}) { logger.Info(args...) } func Warn(args ...interface{}) { logger.Warn(args...) } func Error(args ...interface{}) { logger.Error(args...) } func Fatal(args ...interface{}) { logger.Fatal(args...) } func Debugf(template string, args ...interface{}) { logger.Debugf(template, args...) } func Infof(template string, args ...interface{}) { logger.Infof(template, args...) } func Warnf(template string, args ...interface{}) { logger.Warnf(template, args...) } func Errorf(template string, args ...interface{}) { logger.Errorf(template, args...) } func Fatalf(template string, args ...interface{}) { logger.Fatalf(template, args...) } func Close() error { return logger.Close() } func CleanupOldLogs(retentionDays int) error { if retentionDays <= 0 { return fmt.Errorf("retention days must be greater than 0") } cutoffTime := time.Now().AddDate(0, 0, -retentionDays) return filepath.Walk(basePath, func(path string, info os.FileInfo, err error) error { if err != nil { Warnf("Error accessing path %s: %v", path, err) return nil } if info.IsDir() { return nil } if !strings.HasSuffix(info.Name(), ".log") { return nil } if info.ModTime().Before(cutoffTime) { if err := os.Remove(path); err != nil { Warnf("Failed to remove old log file %s: %v", path, err) } else { Infof("Removed old log file: %s", path) } } return nil }) } func GetLogFileList(path string) ([]uint64, error) { var timestamps []uint64 fullPath := filepath.Join(basePath, path) if _, err := os.Stat(fullPath); os.IsNotExist(err) { return timestamps, nil } entries, err := os.ReadDir(fullPath) if err != nil { return nil, fmt.Errorf("failed to read directory %s: %w", fullPath, err) } for _, entry := range entries { if entry.IsDir() { continue } filename := entry.Name() if !strings.HasSuffix(filename, ".log") { continue } timeStr := strings.TrimSuffix(filename, ".log") if len(timeStr) != 14 { continue } timestamp, err := strconv.ParseUint(timeStr, 10, 64) if err != nil { continue } if _, err := time.Parse("20060102150405", timeStr); err != nil { continue } timestamps = append(timestamps, timestamp) } sort.Slice(timestamps, func(i, j int) bool { return timestamps[i] > timestamps[j] }) return timestamps, nil } func StreamLogToHTTP(path string, timestamp uint64, writer io.Writer) error { timeStr := strconv.FormatUint(timestamp, 10) if len(timeStr) != 14 { return fmt.Errorf("invalid timestamp format: %d", timestamp) } if _, err := time.Parse("20060102150405", timeStr); err != nil { return fmt.Errorf("invalid timestamp: %d", timestamp) } filename := timeStr + ".log" fullPath := filepath.Join(basePath, path, filename) file, err := os.Open(fullPath) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("log file not found: %s", filename) } return fmt.Errorf("failed to open file: %w", err) } defer file.Close() scanner := bufio.NewScanner(file) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) flusher, canFlush := writer.(http.Flusher) isFirstLine := true for scanner.Scan() { lineBytes := scanner.Bytes() if len(lineBytes) == 0 { continue } if !isFirstLine { if _, err := writer.Write(separator); err != nil { return fmt.Errorf("failed to write separator: %w", err) } } if _, err := writer.Write(lineBytes); err != nil { return fmt.Errorf("failed to write log line: %w", err) } if canFlush { flusher.Flush() } isFirstLine = false } if err := scanner.Err(); err != nil { return fmt.Errorf("error reading file: %w", err) } return nil } func DeleteLog(path string) error { if path == "" { return fmt.Errorf("path cannot be empty") } fullPath := filepath.Join(basePath, path) if _, err := os.Stat(fullPath); os.IsNotExist(err) { return nil } if err := os.RemoveAll(fullPath); err != nil { return fmt.Errorf("failed to remove directory %s: %w", path, err) } Debugf("Successfully removed log dir: %s", path) return nil } ================================================ FILE: internal/utils/shutdown/shutdown.go ================================================ package shutdown import ( "os" "os/signal" "syscall" "github.com/bestruirui/bestsub/internal/utils/log" ) var funcs []func() error func Register(fn func() error) { funcs = append(funcs, fn) } func Listen() { quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) log.Info("Program started, press Ctrl+C to exit") sig := <-quit log.Warnf("Received exit signal: %v, starting to close program", sig) if len(funcs) == 0 { return } for _, fn := range funcs { if err := fn(); err != nil { log.Errorf("Closing functions execution failed: %v", err) } } log.Info("=== Shutdown completed successfully ===") os.Exit(0) } func All() { if len(funcs) == 0 { return } for _, fn := range funcs { if err := fn(); err != nil { log.Errorf("Closing functions execution failed: %v", err) } } log.Info("Shutdown completed successfully") } ================================================ FILE: internal/utils/ua/ua.go ================================================ package ua import ( "math/rand" "net/http" ) func SetHeader(req *http.Request) { req.Header.Set("User-Agent", Random()) } func Random() string { return "Mozilla/5.0 (" + platforms[rand.Intn(len(platforms))] + ") AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + chromeVersions[rand.Intn(len(chromeVersions))] + " Safari/537.36" } var ( chromeVersions = []string{ "139.0.7258.128", "139.0.7258.67", "138.0.7204.185", "138.0.7204.170", "138.0.7204.159", "138.0.7204.102", "138.0.7204.100", "138.0.7204.51", "138.0.7204.49", "137.0.7151.122", "138.0.7204.35", "137.0.7151.121", "137.0.7151.105", "137.0.7151.104", "137.0.7151.57", "137.0.7151.55", "136.0.7103.116", "137.0.7151.40", "136.0.7103.113", "136.0.7103.92", "135.0.7049.117", "136.0.7103.48", "135.0.7049.114", "135.0.7049.86", "135.0.7049.42", "135.0.7049.41", "134.0.6998.167", "134.0.6998.119", "134.0.6998.117", "134.0.6998.37", "134.0.6998.35", "133.0.6943.128", "133.0.6943.100", "133.0.6943.59", "133.0.6943.53", "132.0.6834.162", "133.0.6943.35", "132.0.6834.160", "132.0.6834.112", "132.0.6834.110", "131.0.6778.267", "132.0.6834.83", "131.0.6778.264", "131.0.6778.204", "131.0.6778.139", "131.0.6778.109", "131.0.6778.71", "131.0.6778.69", "130.0.6723.119", "131.0.6778.33", "130.0.6723.116", "130.0.6723.71", "130.0.6723.60", "130.0.6723.58", "129.0.6668.103", "130.0.6723.44", "129.0.6668.100", "129.0.6668.72", "129.0.6668.60", "129.0.6668.42", "128.0.6613.122", "128.0.6613.121", "128.0.6613.115", "128.0.6613.113", "127.0.6533.122", "128.0.6613.36", "127.0.6533.119", "127.0.6533.100", "127.0.6533.74", "127.0.6533.72", "126.0.6478.185", "127.0.6533.57", "126.0.6478.183", "126.0.6478.128", "126.0.6478.116", "126.0.6478.114", "126.0.6478.61", "125.0.6422.176", "126.0.6478.56", "125.0.6422.144", "126.0.6478.36", "125.0.6422.142", "125.0.6422.114", "125.0.6422.77", "125.0.6422.76", "124.0.6367.210", "125.0.6422.60", "124.0.6367.208", "124.0.6367.201", "124.0.6367.156", "125.0.6422.41", "124.0.6367.155", "124.0.6367.119", "124.0.6367.92", "124.0.6367.63", "124.0.6367.61", "123.0.6312.124", "124.0.6367.60", "123.0.6312.122", "123.0.6312.106", "123.0.6312.105", "123.0.6312.60", "123.0.6312.58", "122.0.6261.131", "123.0.6312.46", "122.0.6261.129", "122.0.6261.128", "122.0.6261.112", "122.0.6261.111", "122.0.6261.71", "122.0.6261.69", "121.0.6167.189", "122.0.6261.57", "121.0.6167.187", "121.0.6167.186", "121.0.6167.162", "121.0.6167.160", "121.0.6167.140", "121.0.6167.86", "121.0.6167.85", "120.0.6099.227", "120.0.6099.225", "121.0.6167.75", "120.0.6099.224", "120.0.6099.218", "120.0.6099.216", "120.0.6099.200", "120.0.6099.199", "120.0.6099.129", "120.0.6099.110", "120.0.6099.109", "120.0.6099.62", "120.0.6099.56", } platforms = []string{ "Windows NT 10.0; Win64; x64", "Macintosh; Intel Mac OS X 10_15_7", } ) ================================================ FILE: internal/utils/utils.go ================================================ package utils import ( "fmt" "os" "path/filepath" "strconv" "strings" "unicode" "unicode/utf8" ) // 检查目录是否可写 func IsWritableDir(dir string) bool { // 尝试在目录中创建临时文件 testFile := filepath.Join(dir, ".write_test") file, err := os.Create(testFile) if err != nil { return false } file.Close() os.Remove(testFile) return true } // 检查字符串切片是否包含指定字符串 func Contains(slice []string, item string) bool { for _, s := range slice { if s == item { return true } } return false } func RemoveAllControlCharacters(data *[]byte) { var cleanedData []byte original := *data for len(original) > 0 { r, size := utf8.DecodeRune(original) if r != utf8.RuneError && (r >= 32 && r <= 126) || r == '\n' || r == '\t' || r == '\r' || unicode.Is(unicode.Han, r) { cleanedData = append(cleanedData, original[:size]...) } original = original[size:] } *data = cleanedData } func IsDebug() bool { debug := os.Getenv("BESTSUB_DEBUG") return strings.ToLower(debug) == "true" } // IPToUint32 将IP地址转换为uint32 func IPToUint32(ip string) uint32 { ip = strings.TrimSpace(ip) if ip == "" { return 0 } parts := strings.Split(ip, ".") if len(parts) != 4 { return 0 } var result uint32 for i, part := range parts { partInt, err := strconv.Atoi(part) if err != nil || partInt < 0 || partInt > 255 { return 0 } result |= uint32(partInt) << ((3 - i) * 8) } return result } // Uint32ToIP 将uint32转换为IP地址 func Uint32ToIP(ip uint32) string { return fmt.Sprintf("%d.%d.%d.%d", (ip>>24)&0xFF, (ip>>16)&0xFF, (ip>>8)&0xFF, ip&0xFF) } ================================================ FILE: scripts/build.sh ================================================ #!/bin/bash # Exit on any error, but handle errors gracefully set -e # Enable error trapping trap 'handle_error $? $LINENO' ERR # ============================================================================= # Configuration # ============================================================================= # Project configuration readonly APP_NAME="bestsub" readonly MAIN_DIR="./cmd/bestsub" readonly OUTPUT_DIR="build" readonly TOOLCHAIN_DIR="$HOME/.bestsub/toolchains" # Build metadata readonly BUILD_TIME="$(TZ='Asia/Shanghai' date +'%F %T %z')" readonly GIT_AUTHOR="bestrui" readonly GIT_VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo 'dev')" readonly COMMIT_ID="$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')" # Build flags readonly LDFLAGS="-X 'github.com/bestruirui/bestsub/internal/utils/info.Version=${GIT_VERSION}' \ -X 'github.com/bestruirui/bestsub/internal/utils/info.BuildTime=${BUILD_TIME}' \ -X 'github.com/bestruirui/bestsub/internal/utils/info.Author=${GIT_AUTHOR}' \ -X 'github.com/bestruirui/bestsub/internal/utils/info.Commit=${COMMIT_ID}' \ -s -w" # Android NDK configuration readonly ANDROID_NDK_VERSION="r27c" readonly ANDROID_NDK_BASE="https://dl.google.com/android/repository/" readonly ANDROID_NDK_PATH="${TOOLCHAIN_DIR}/android-ndk/android-ndk-${ANDROID_NDK_VERSION}/toolchains/llvm/prebuilt/linux-x86_64/bin" # ============================================================================= # Utility Functions # ============================================================================= log_info() { echo "ℹ️ $1" } log_success() { echo "✅ $1" } log_error() { echo "❌ $1" >&2 } log_warning() { echo "⚠️ $1" >&2 } log_step() { echo "" echo "🔧 $1" echo "────────────────────────────────────────" } # Error handling function handle_error() { local exit_code=$1 local line_number=$2 log_error "Build failed at line ${line_number} with exit code ${exit_code}" log_error "Command that failed: $(sed -n "${line_number}p" "$0" | xargs)" log_error "Check the output above for more details" exit $exit_code } # Check if command exists command_exists() { command -v "$1" >/dev/null 2>&1 } # Install command if not exists (Linux/macOS) install_command() { local cmd="$1" local package="$2" if command_exists "$cmd"; then log_info "$cmd is already installed" return 0 fi log_info "Installing $cmd..." # Detect OS and package manager if [[ "$OSTYPE" == "linux-gnu"* ]]; then if command_exists apt-get; then sudo apt-get update >/dev/null 2>&1 || { log_error "Failed to update package list" return 1 } sudo apt-get install -y "$package" >/dev/null 2>&1 || { log_error "Failed to install $package using apt-get" return 1 } elif command_exists yum; then sudo yum install -y "$package" >/dev/null 2>&1 || { log_error "Failed to install $package using yum" return 1 } elif command_exists dnf; then sudo dnf install -y "$package" >/dev/null 2>&1 || { log_error "Failed to install $package using dnf" return 1 } elif command_exists pacman; then sudo pacman -S --noconfirm "$package" >/dev/null 2>&1 || { log_error "Failed to install $package using pacman" return 1 } else log_error "No supported package manager found. Please install $cmd manually" return 1 fi elif [[ "$OSTYPE" == "darwin"* ]]; then if command_exists brew; then brew install "$package" >/dev/null 2>&1 || { log_error "Failed to install $package using brew" return 1 } else log_error "Homebrew not found. Please install $cmd manually or install Homebrew first" return 1 fi else log_error "Unsupported OS: $OSTYPE. Please install $cmd manually" return 1 fi log_success "$cmd installed successfully" } # ============================================================================= # Setup Functions # ============================================================================= prepare_environment() { log_step "Preparing build environment" # Check and install required commands log_info "Checking required commands..." # Check Go if ! command_exists go; then log_error "Go is not installed. Please install Go from https://golang.org/dl/" return 1 fi local go_version=$(go version 2>/dev/null | grep -o 'go[0-9]\+\.[0-9]\+' | head -1) log_success "Go version: $go_version" # Check git if ! command_exists git; then install_command git git || return 1 fi # Check curl if ! command_exists curl; then install_command curl curl || return 1 fi # Check unzip if ! command_exists unzip; then install_command unzip unzip || return 1 fi # Check tar if ! command_exists tar; then install_command tar tar || return 1 fi # Check zip if ! command_exists zip; then install_command zip zip || return 1 fi # Check md5sum (or md5 on macOS) if ! command_exists md5sum && ! command_exists md5; then if [[ "$OSTYPE" == "darwin"* ]]; then log_warning "md5sum not found, but md5 is available on macOS" else install_command md5sum coreutils || return 1 fi fi log_success "All required commands installed" # Create output directory and subdirectories log_info "Creating output directory structure: ${OUTPUT_DIR}" # Check if OUTPUT_DIR exists (including symlinks) if [ -e "${OUTPUT_DIR}" ]; then if [ -d "${OUTPUT_DIR}" ]; then log_success "Output directory already exists: ${OUTPUT_DIR}" else log_error "Output path exists but is not a directory: ${OUTPUT_DIR}" log_error "Path type: $(ls -la "${OUTPUT_DIR}" 2>/dev/null || echo 'Cannot determine type')" return 1 fi else # Try to create the directory if ! mkdir -p "${OUTPUT_DIR}"; then log_error "Failed to create output directory: ${OUTPUT_DIR}" log_error "Current working directory: $(pwd)" log_error "Directory permissions: $(ls -la . 2>/dev/null || echo 'Cannot list directory')" return 1 fi log_success "Created output directory: ${OUTPUT_DIR}" fi # Create subdirectories for organized output local subdirs=("bin" "docker" "archives") for subdir in "${subdirs[@]}"; do if ! mkdir -p "${OUTPUT_DIR}/${subdir}"; then log_error "Failed to create subdirectory: ${OUTPUT_DIR}/${subdir}" return 1 fi done log_success "Created output subdirectories: bin, docker, archives" log_info "Tidying Go modules..." if ! go mod tidy >/dev/null 2>&1; then log_error "Failed to tidy Go modules" return 1 fi log_success "Build environment ready" } # ============================================================================= # Build Functions # ============================================================================= build_frontend() { log_step "Building frontend" local web_dir="web" # Check if web directory exists if [ ! -d "$web_dir" ]; then log_error "Web directory not found: $web_dir" log_error "Please run this script from the project root directory" return 1 fi # Change to web directory cd "$web_dir" || return 1 # Install dependencies log_info "Installing frontend dependencies..." if ! pnpm install; then log_error "Failed to install frontend dependencies" cd .. return 1 fi log_success "Frontend dependencies installed" # Build the project log_info "Building frontend project..." if ! NEXT_PUBLIC_APP_VERSION="$GIT_VERSION" pnpm run build; then log_error "Failed to build frontend project" cd .. return 1 fi log_success "Frontend build completed" # Return to original directory cd .. # Move out directory to static directory log_info "Moving frontend output to static directory..." # Remove old static/out if exists if [ -d "static/out" ]; then rm -rf "static/out" log_info "Removed old static/out directory" fi # Move web/out to static/out if [ -d "${web_dir}/out" ]; then mv "${web_dir}/out" "static/" log_success "Moved frontend output to static/out" else log_error "Frontend output directory not found: ${web_dir}/out" return 1 fi return 0 } setup_android_ndk() { log_step "Setting up Android NDK" if [ -d "${TOOLCHAIN_DIR}/android-ndk" ]; then log_success "Android NDK ${ANDROID_NDK_VERSION} already installed" return 0 fi local ndk_zip="/tmp/android-ndk-${ANDROID_NDK_VERSION}.zip" local ndk_url="${ANDROID_NDK_BASE}android-ndk-${ANDROID_NDK_VERSION}-linux.zip" log_info "Downloading Android NDK ${ANDROID_NDK_VERSION}..." if ! curl -L -o "${ndk_zip}" "${ndk_url}" >/dev/null 2>&1; then log_error "Failed to download Android NDK from ${ndk_url}" return 1 fi log_info "Extracting Android NDK..." if ! mkdir -p "${TOOLCHAIN_DIR}/android-ndk"; then log_error "Failed to create NDK directory: ${TOOLCHAIN_DIR}/android-ndk" log_error "Toolchain directory: ${TOOLCHAIN_DIR}" log_error "Home directory permissions: $(ls -la "$HOME" 2>/dev/null | head -5 || echo 'Cannot list home directory')" return 1 fi if ! unzip -q "${ndk_zip}" -d "${TOOLCHAIN_DIR}/android-ndk" 2>/dev/null; then log_error "Failed to extract Android NDK" rm -f "${ndk_zip}" return 1 fi rm -f "${ndk_zip}" log_success "Android NDK ${ANDROID_NDK_VERSION} installed" } # ============================================================================= # Build Functions # ============================================================================= get_go_arch() { case "$1" in "x86_64") echo "amd64" ;; "arm64") echo "arm64" ;; "x86") echo "386" ;; "armv7") echo "arm" ;; *) log_error "Unsupported architecture: $1" return 1 ;; esac } build_standard() { local os="$1" local arch="$2" local go_arch if ! go_arch="$(get_go_arch "${arch}")"; then log_error "Failed to get Go architecture: ${arch}" return 1 fi local output_file="${OUTPUT_DIR}/bin/${APP_NAME}-${os}-${arch}" log_info "Building ${os}/${arch}..." if ! GOOS="${os}" GOARCH="${go_arch}" CGO_ENABLED=0 \ go build -o "${output_file}" -ldflags="${LDFLAGS}" -tags=jsoniter "${MAIN_DIR}" 2>&1; then log_error "Failed to build ${os}/${arch}" log_error "Build command: GOOS=${os} GOARCH=${go_arch} CGO_ENABLED=0 go build -o ${output_file} -ldflags=\"${LDFLAGS}\" -tags=jsoniter ${MAIN_DIR}" return 1 fi if [ ! -f "${output_file}" ]; then log_error "Build completed but output file not found: ${output_file}" return 1 fi log_success "Built ${os}/${arch} → bin/$(basename "${output_file}")" } get_android_compiler() { case "$1" in "x86_64") echo "x86_64-linux-android24-clang" ;; "arm64") echo "aarch64-linux-android24-clang" ;; "armv7") echo "armv7a-linux-androideabi24-clang" ;; "x86") echo "i686-linux-android24-clang" ;; *) log_error "Unsupported Android architecture: $1" return 1 ;; esac } build_android() { local arch="$1" local go_arch local compiler if ! go_arch="$(get_go_arch "${arch}")"; then log_error "Failed to normalize architecture: ${arch}" return 1 fi if ! compiler="$(get_android_compiler "${arch}")"; then log_error "Failed to get Android compiler for architecture: ${arch}" return 1 fi local compiler_path="${ANDROID_NDK_PATH}/${compiler}" if [ ! -f "${compiler_path}" ]; then log_error "Android compiler not found: ${compiler_path}" log_error "Make sure Android NDK is properly installed" return 1 fi local output_file="${OUTPUT_DIR}/bin/${APP_NAME}-android-${arch}" log_info "Building android/${arch}..." if ! GOOS=android GOARCH="${go_arch}" CC="${compiler_path}" CGO_ENABLED=1 \ go build -o "${output_file}" -ldflags="${LDFLAGS}" -tags=jsoniter "${MAIN_DIR}" 2>&1; then log_error "Failed to build android/${arch}" log_error "Build command: GOOS=android GOARCH=${go_arch} CC=${compiler_path} CGO_ENABLED=1 go build -o ${output_file} -ldflags=\"${LDFLAGS}\" -tags=jsoniter ${MAIN_DIR}" return 1 fi if [ ! -f "${output_file}" ]; then log_error "Build completed but output file not found: ${output_file}" return 1 fi # Strip binary to reduce size local strip_tool="${ANDROID_NDK_PATH}/llvm-strip" if [ -f "${strip_tool}" ]; then if ! "${strip_tool}" "${output_file}" 2>/dev/null; then log_warning "Failed to strip binary, but build was successful" fi else log_warning "Strip tool not found: ${strip_tool}" fi log_success "Built android/${arch} → bin/$(basename "${output_file}")" } # ============================================================================= # Post-build Functions # ============================================================================= create_archives() { log_step "Creating distribution archives" local archives_dir="${OUTPUT_DIR}/archives" # Copy documentation files to archives directory cp README.md LICENSE "${archives_dir}/" 2>/dev/null || log_info "Documentation files not found, skipping" # Archive all binaries (zip format for all platforms) while IFS= read -r -d '' file; do local basename_file basename_file=$(basename "$file") local extension="" # Add .exe extension for Windows binaries if [[ "$basename_file" == *"-windows-"* ]]; then extension=".exe" fi if ! cp "$file" "${archives_dir}/${APP_NAME}${extension}" 2>/dev/null; then log_error "Failed to copy $file to ${archives_dir}/${APP_NAME}${extension}" continue fi if (cd "${archives_dir}" && zip -q "${basename_file}.zip" "${APP_NAME}${extension}" README.md LICENSE 2>/dev/null); then rm -f "${archives_dir}/${APP_NAME}${extension}" log_success "Archived: archives/${basename_file}.zip" else log_error "Failed to create archive: ${basename_file}.zip" rm -f "${archives_dir}/${APP_NAME}${extension}" fi done < <(find "${OUTPUT_DIR}/bin/" -name "${APP_NAME}-*" -type f -print0 2>/dev/null) # Cleanup documentation files from archives directory rm -f "${archives_dir}/README.md" "${archives_dir}/LICENSE" if ! cd .. 2>/dev/null; then log_error "Failed to return to parent directory" return 1 fi log_success "Created archives in ${archives_dir}/" } generate_checksums() { log_step "Generating checksums" local bin_dir="${OUTPUT_DIR}/bin" if ! cd "${bin_dir}" 2>/dev/null; then log_error "Failed to change to bin directory: ${bin_dir}" return 1 fi if ! find . -maxdepth 1 -name "${APP_NAME}-*" -type f | head -1 | grep -q .; then log_info "No build artifacts found in bin directory, skipping checksums" cd ../.. 2>/dev/null || true return 0 fi # Use appropriate checksum command based on OS local checksum_cmd if command_exists md5sum; then checksum_cmd="md5sum" elif command_exists md5; then checksum_cmd="md5 -r" # -r for BSD md5 to match md5sum format else log_error "No checksum command available (md5sum or md5)" cd ../.. 2>/dev/null || true return 1 fi if find . -maxdepth 1 -name "${APP_NAME}-*" -type f -print0 | xargs -0 $checksum_cmd >md5.txt 2>/dev/null; then local checksum_count=$(wc -l /dev/null || echo "0") log_success "Generated checksums for ${checksum_count} files in bin/" else log_error "Failed to generate checksums" cd ../.. 2>/dev/null || true return 1 fi if ! cd ../.. 2>/dev/null; then log_error "Failed to return to parent directory" return 1 fi } prepare_docker_binaries() { log_step "Preparing Docker binaries" local docker_dir="${OUTPUT_DIR}/docker" # Create docker directory under OUTPUT_DIR if ! mkdir -p "${docker_dir}"; then log_error "Failed to create docker directory: ${docker_dir}" log_error "Current working directory: $(pwd)" log_error "Directory permissions: $(ls -la . 2>/dev/null || echo 'Cannot list directory')" return 1 fi local platforms=( "x86_64:linux/amd64" "x86:linux/386" "armv7:linux/arm/v7" "arm64:linux/arm64" ) local copied_count=0 for platform in "${platforms[@]}"; do local arch="${platform%%:*}" local docker_platform="${platform#*:}" local binary_name="${APP_NAME}-linux-${arch}" local platform_dir="${docker_dir}/${docker_platform}" if ! mkdir -p "${platform_dir}"; then log_error "Failed to create directory: ${platform_dir}" log_error "Docker platform: ${docker_platform}" continue fi # Try to copy from binary file first if [ -f "${OUTPUT_DIR}/bin/${binary_name}" ]; then if cp "${OUTPUT_DIR}/bin/${binary_name}" "${platform_dir}/${APP_NAME}" 2>/dev/null; then log_success "Copied bin/${binary_name} → docker/${docker_platform}/${APP_NAME}" ((copied_count++)) else log_error "Failed to copy bin/${binary_name} to ${platform_dir}/${APP_NAME}" fi else log_warning "Binary not found: bin/${binary_name}" fi done if [ $copied_count -gt 0 ]; then log_success "Prepared ${copied_count} Docker binaries in ${docker_dir}/" else log_warning "No Docker binaries prepared" fi } # ============================================================================= # Main Execution # ============================================================================= show_usage() { echo "Usage: $0 [os] [arch]" echo "" echo "Commands:" echo " release Build all platforms and create distribution packages" echo " build Build for specific OS and architecture" echo " help Show this help message" echo "" echo "Supported OS:" echo " linux, windows, darwin, android" echo "" echo "Supported architectures:" echo " x86_64, arm64, armv7, x86" echo "" echo "Examples:" echo " $0 build windows x86_64" echo " $0 build linux x86_64" echo " $0 build android arm64" echo " $0 release" } validate_os_arch() { local os="$1" local arch="$2" # Validate OS case "$os" in "linux" | "windows" | "darwin" | "android") ;; *) log_error "Unsupported OS: $os" log_error "Supported OS: linux, windows, darwin, android" return 1 ;; esac # Validate architecture case "$arch" in "x86_64" | "arm64" | "armv7" | "x86") ;; *) log_error "Unsupported architecture: $arch" log_error "Supported architectures: x86_64, arm64, armv7, x86" return 1 ;; esac return 0 } main() { case "${1:-}" in "build") if [ $# -ne 3 ]; then log_error "Build command requires OS and architecture" log_error "Usage: $0 build " show_usage exit 1 fi local os="$2" local arch="$3" if ! validate_os_arch "$os" "$arch"; then exit 1 fi log_step "Starting single platform build" echo "📦 Building ${APP_NAME} ${GIT_VERSION} (${COMMIT_ID}) for ${os}/${arch}" echo "" # Setup if ! prepare_environment; then log_error "Failed to prepare build environment" exit 1 fi # Setup Android NDK if building for Android if [ "$os" = "android" ]; then if ! setup_android_ndk; then log_error "Failed to setup Android NDK" exit 1 fi fi # Build for specified platform log_step "Building binary" if [ "$os" = "android" ]; then if ! build_android "$arch"; then log_error "Failed to build ${os}/${arch}" exit 1 fi else if ! build_standard "$os" "$arch"; then log_error "Failed to build ${os}/${arch}" exit 1 fi fi log_step "Build completed" log_success "Binary ready: ${OUTPUT_DIR}/bin/${APP_NAME}-${os}-${arch}" ;; "release") log_step "Starting release build" echo "📦 Building ${APP_NAME} ${GIT_VERSION} (${COMMIT_ID})" echo "" # Setup if ! prepare_environment; then log_error "Failed to prepare build environment" exit 1 fi # Build frontend if ! build_frontend; then log_error "Failed to build frontend" exit 1 fi # if ! setup_android_ndk; then # log_error "Failed to setup Android NDK" # exit 1 # fi # Build for different platforms log_step "Building binaries" # Android builds (requires CGO and NDK) # if ! build_android arm64; then # log_error "Failed to build Android arm64" # fi # Standard builds (pure Go, static binaries) if ! build_standard linux x86_64; then log_error "Failed to build Linux x86_64" fi if ! build_standard linux arm64; then log_error "Failed to build Linux arm64" fi if ! build_standard linux armv7; then log_error "Failed to build Linux armv7" fi if ! build_standard linux x86; then log_error "Failed to build Linux x86" fi if ! build_standard windows x86_64; then log_error "Failed to build Windows x86_64" fi if ! build_standard windows x86; then log_error "Failed to build Windows x86" fi if ! build_standard darwin arm64; then log_error "Failed to build Darwin arm64" fi if ! build_standard darwin x86_64; then log_error "Failed to build Darwin arm64" fi # Post-processing if ! prepare_docker_binaries; then log_warning "Failed to prepare Docker binaries, but continuing..." fi if ! generate_checksums; then log_warning "Failed to generate checksums, but continuing..." fi if ! create_archives; then log_warning "Failed to create archives, but continuing..." fi log_step "Build completed" log_success "All artifacts ready in ${OUTPUT_DIR}/" log_info " • Binaries: ${OUTPUT_DIR}/bin/" log_info " • Docker binaries: ${OUTPUT_DIR}/docker/" log_info " • Archives: ${OUTPUT_DIR}/archives/" ;; "help" | "-h" | "--help") show_usage ;; "") log_error "No command specified" show_usage exit 1 ;; *) log_error "Unknown command: $1" show_usage exit 1 ;; esac } main "$@" ================================================ FILE: scripts/dockerfiles/Dockerfile.alpine ================================================ FROM alpine ARG TARGETPLATFORM ENV TZ=Asia/Shanghai RUN apk add --no-cache alpine-conf ca-certificates su-exec && \ /usr/sbin/setup-timezone -z Asia/Shanghai && \ apk del alpine-conf && \ rm -rf /var/cache/apk/* && \ mkdir -p /app COPY build/docker/${TARGETPLATFORM}/bestsub /app/bestsub COPY scripts/dockerfiles/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh CMD ["/entrypoint.sh"] ================================================ FILE: scripts/dockerfiles/Dockerfile.debian ================================================ FROM debian:bookworm ARG TARGETPLATFORM ENV TZ=Asia/Shanghai RUN apt-get update && apt-get install -y ca-certificates tzdata gosu && \ ln -fs /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ dpkg-reconfigure -f noninteractive tzdata && \ rm -rf /var/cache/apt/* && \ mkdir -p /app COPY build/docker/${TARGETPLATFORM}/bestsub /app/bestsub COPY scripts/dockerfiles/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh CMD ["/entrypoint.sh"] ================================================ FILE: scripts/dockerfiles/entrypoint.sh ================================================ #!/bin/sh set -e PUID="${PUID:-0}" PGID="${PGID:-0}" chmod +x /app/bestsub if [ "$PUID" != "0" ] || [ "$PGID" != "0" ]; then chown -R "$PUID:$PGID" /app fi cd /app if command -v su-exec >/dev/null 2>&1; then exec su-exec "$PUID:$PGID" ./bestsub -c data/config.json elif command -v gosu >/dev/null 2>&1; then exec gosu "$PUID:$PGID" ./bestsub -c data/config.json else if [ "$PUID" != "0" ] || [ "$PGID" != "0" ]; then echo "Warning: neither su-exec nor gosu is available; running as root." >&2 fi exec ./bestsub -c data/config.json fi ================================================ FILE: static/static.go ================================================ package static import ( "embed" "io/fs" ) //go:embed all:out var staticFS embed.FS // StaticFS 返回 out 子目录的文件系统 var StaticFS, _ = fs.Sub(staticFS, "out") ================================================ FILE: web/.env.example ================================================ # API 配置 # API 基础 URL,如果为空则使用当前域名 # 例如:https://api.example.com 或 http://localhost:8080 NEXT_PUBLIC_API_BASEURL= ================================================ FILE: web/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/versions # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) .env* !.env.example # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts ================================================ FILE: web/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "", "css": "app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/src/components", "utils": "@/src/utils", "ui": "@/src/components/ui", "lib": "@/src/lib", "hooks": "@/src/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: web/eslint.config.mjs ================================================ import { dirname } from "path"; import { fileURLToPath } from "url"; import { FlatCompat } from "@eslint/eslintrc"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const compat = new FlatCompat({ baseDirectory: __dirname, }); const eslintConfig = [ ...compat.extends("next/core-web-vitals", "next/typescript"), { rules: { "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], "@typescript-eslint/no-explicit-any": "warn", "react-hooks/exhaustive-deps": "warn", "react/jsx-key": "error", "no-console": ["warn", { allow: ["warn", "error"] }], "prefer-const": "error", "no-var": "error", }, }, ]; export default eslintConfig; ================================================ FILE: web/next.config.ts ================================================ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: 'export', trailingSlash: true, skipTrailingSlashRedirect: true, images: { unoptimized: true, }, compress: true, assetPrefix: process.env.NODE_ENV === 'production' ? '' : '', experimental: { optimizePackageImports: [ '@radix-ui/react-icons', '@tabler/icons-react', 'lucide-react' ], }, }; export default nextConfig; ================================================ FILE: web/package.json ================================================ { "name": "bestsub", "version": "0.1.0", "private": true, "scripts": { "dev": "NEXT_PUBLIC_APP_VERSION=$(git describe --tags --abbrev=0) next dev --turbopack", "build": "NEXT_PUBLIC_APP_VERSION=$(git describe --tags --abbrev=0) next build", "start": "next start", "lint": "next lint" }, "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-hover-card": "^1.1.14", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@tabler/icons-react": "^3.34.1", "@tanstack/react-query": "^5.85.8", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.534.0", "marked": "^16.2.1", "next": "15.4.5", "next-themes": "^0.4.6", "react": "19.1.0", "react-day-picker": "^9.8.1", "react-dom": "19.1.0", "react-hook-form": "^7.62.0", "recharts": "^2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "vaul": "^1.1.2", "zod": "^4.0.14", "zustand": "^5.0.7" }, "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.4.5", "tailwindcss": "^4", "tw-animate-css": "^1.3.6", "typescript": "^5" } } ================================================ FILE: web/postcss.config.mjs ================================================ const config = { plugins: ["@tailwindcss/postcss"], }; export default config; ================================================ FILE: web/src/app/globals.css ================================================ @import "tailwindcss"; @import "tw-animate-css"; @custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar: var(--sidebar); --color-chart-5: var(--chart-5); --color-chart-4: var(--chart-4); --color-chart-3: var(--chart-3); --color-chart-2: var(--chart-2); --color-chart-1: var(--chart-1); --color-ring: var(--ring); --color-input: var(--input); --color-border: var(--border); --color-destructive: var(--destructive); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); --color-muted-foreground: var(--muted-foreground); --color-muted: var(--muted); --color-secondary-foreground: var(--secondary-foreground); --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); --color-primary: var(--primary); --color-popover-foreground: var(--popover-foreground); --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); } :root { --radius: 0.625rem; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); } .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); } @layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; } } /* 隐藏滚动条但保持滚动功能 */ .scrollbar-hide { -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ } .scrollbar-hide::-webkit-scrollbar { display: none; /* Chrome, Safari and Opera */ } ================================================ FILE: web/src/app/layout.tsx ================================================ import "./globals.css"; import { ThemeProvider, AuthProvider, AlertProvider } from "@/src/components/providers"; import { QueryProvider } from "@/src/components/providers/query-provider"; import { Toaster } from "sonner"; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( {children} ); } ================================================ FILE: web/src/app/not-found.tsx ================================================ "use client" import { useEffect } from "react" import { SPAApp } from "@/src/components/app" export default function NotFound() { useEffect(() => { const currentPath = window.location.pathname if (currentPath !== '/') { window.location.href = `/#${currentPath}${window.location.search}${window.location.hash}` } }, []) return } ================================================ FILE: web/src/app/page.tsx ================================================ "use client" import { SPAApp } from "@/src/components/app" export default function Home() { return } ================================================ FILE: web/src/components/app/app-layout.tsx ================================================ "use client" import { useRouter } from "@/src/router/core/context" import { useAuth } from "@/src/components/providers" import { useRouteTitle, useRoutePreloader } from "@/src/router" import { AppSidebar, SiteHeader } from "@/src/components/layout" import { SidebarInset, SidebarProvider } from "@/src/components/ui/sidebar" import { PageLoading } from "@/src/components/ui/loading" import { RouterOutlet } from "@/src/router/core/outlet" export function AppLayout() { const { currentPath, routes } = useRouter() const { isLoading } = useAuth() useRoutePreloader() useRouteTitle() if (isLoading) { return } const currentRoute = routes.find(route => route.path === currentPath) const isProtectedRoute = currentRoute?.protected || false if (isProtectedRoute) { return (
) } return (
) } ================================================ FILE: web/src/components/app/index.ts ================================================ export { SPAApp } from './spa-app' export { AppLayout } from './app-layout' ================================================ FILE: web/src/components/app/spa-app.tsx ================================================ "use client" import { RouterProvider } from "@/src/router/core/router" import { AppLayout } from "./app-layout" export function SPAApp() { return ( ) } ================================================ FILE: web/src/components/features/check/components/check-form.tsx ================================================ import { Button } from "@/src/components/ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/src/components/ui/dialog" import { useCheckForm } from "../hooks" import { UI_TEXT } from "../constants" import { BasicInfoSection, BasicConfigSection, NotifyConfig, LogConfig, ExtraConfigSection } from "./form-sections" import { SubscriptionSection } from "@/src/components/shared/subscription-section" import type { CheckRequest } from "@/src/types/check" interface CheckFormProps { initialData?: CheckRequest | undefined formTitle: string isOpen: boolean onClose: () => void editingCheckId?: number | undefined } export function CheckForm({ initialData, formTitle, isOpen, onClose, editingCheckId, }: CheckFormProps) { const { form, onSubmit, isEditing } = useCheckForm({ initialData, editingCheckId, onSuccess: onClose, isOpen, }) const { control } = form return ( {formTitle}
) } ================================================ FILE: web/src/components/features/check/components/check-list.tsx ================================================ import { useEffect } from "react" import { Button } from "@/src/components/ui/button" import { Badge } from "@/src/components/ui/badge" import { Card, CardContent } from "@/src/components/ui/card" import { Table, TableBody, TableCell, TableRow } from "@/src/components/ui/table" import { InlineLoading } from "@/src/components/ui/loading" import { Play, Edit, Trash2 } from "lucide-react" import { UI_TEXT } from "../constants" import { formatLastRunTime, formatDuration, formatBooleanText } from "@/src/utils" import StatusBadge from "@/src/components/shared/status-badge" import { useOverflowDetection } from "@/src/lib/hooks/useOverflowDetection" import type { CheckResponse } from "@/src/types/check" import { api } from "@/src/lib/api/client" import { useAlert } from '@/src/components/providers' import { useChecks, useDeleteCheck } from "@/src/lib/queries/check-queries" import { toast } from "sonner" interface CheckListProps { onEdit: (check: CheckResponse) => void } export function CheckList({ onEdit }: CheckListProps) { const { confirm } = useAlert() const { data: checks = [], isLoading, error } = useChecks() const deleteCheckMutation = useDeleteCheck() const { containerRef, contentRef, isOverflowing, checkOverflow } = useOverflowDetection() const onDelete = async (id: number, name: string) => { const confirmed = await confirm({ title: UI_TEXT.CONFIRM_DELETE, description: UI_TEXT.DELETE_CONFIRM_MESSAGE.replace('{name}', name), confirmText: UI_TEXT.DELETE, cancelText: UI_TEXT.CANCEL, variant: 'destructive' }) if (confirmed) { try { await deleteCheckMutation.mutateAsync(id) toast.success(UI_TEXT.DELETE_SUCCESS) } catch (error) { toast.error(UI_TEXT.DELETE_FAILED) console.error('Failed to delete check:', error) } } } useEffect(() => { if (!isLoading) { checkOverflow() } }, [isLoading, checkOverflow]) if (isLoading) { return ( ) } if (error) { return (
加载失败: {error.message}
) } if (checks.length === 0) { return (
{UI_TEXT.NO_DATA},点击上方按钮创建第一个检测任务
) } return (
{checks.sort((a, b) => a.id - b.id).map((check) => (
{check.name}
{check.task?.cron_expr || 'N/A'}
{check.task.type}
超时时间: {check.task?.timeout || 0}分钟
通知: {formatBooleanText(check.task?.notify ?? false)}
日志: {formatBooleanText(check.task?.log_write_file ?? false)}
最后运行: {formatLastRunTime(check.result?.last_run)}
执行时长: {formatDuration(check.result?.duration)}
状态消息: {check.result?.msg || '无'}
))}
) } ================================================ FILE: web/src/components/features/check/components/check-page.tsx ================================================ import { useState, useCallback } from "react" import { Button } from "@/src/components/ui/button" import { Plus } from "lucide-react" import { CheckForm } from "./check-form" import { CheckList } from "./check-list" import { UI_TEXT } from "../constants" import { convertCheckResponseToRequest } from "../utils" import type { CheckResponse, CheckRequest } from "@/src/types/check" export function CheckPage() { const [isDialogOpen, setIsDialogOpen] = useState(false) const [editingCheck, setEditingCheck] = useState(null) const [formData, setFormData] = useState(undefined) const openEditDialog = useCallback((check: CheckResponse) => { setEditingCheck(check) setFormData(convertCheckResponseToRequest(check)) setIsDialogOpen(true) }, []) const openCreateDialog = useCallback(() => { setEditingCheck(null) setFormData(undefined) setIsDialogOpen(true) }, []) const closeFormDialog = useCallback(() => { setIsDialogOpen(false) setTimeout(() => { setEditingCheck(null) setFormData(undefined) }, 200) }, []) return (

检测任务

) } ================================================ FILE: web/src/components/features/check/components/form-sections/basic-config-section.tsx ================================================ import { Controller, Control } from 'react-hook-form' import { Input } from '@/src/components/ui/input' import { Label } from '@/src/components/ui/label' import { validateCronExpr, validateTimeout } from '@/src/utils' import { CHECK_CONSTANTS } from '../../constants' import type { CheckRequest } from '@/src/types/check' export function BasicConfigSection({ control }: { control: Control }) { return (
(
field.onChange(validateTimeout(e.target.value))} placeholder={CHECK_CONSTANTS.DEFAULT_TIMEOUT.toString()} min={CHECK_CONSTANTS.MIN_TIMEOUT} max={CHECK_CONSTANTS.MAX_TIMEOUT} />
)} /> (
{field.value && !validateCronExpr(field.value) && (

请输入有效的Cron表达式

)}
)} />
) } ================================================ FILE: web/src/components/features/check/components/form-sections/basic-info-section.tsx ================================================ import { Controller, Control } from 'react-hook-form' import { Input } from '@/src/components/ui/input' import { Label } from '@/src/components/ui/label' import { Switch } from '@/src/components/ui/switch' import type { CheckRequest } from '@/src/types/check' export function BasicInfoSection({ control }: { control: Control }) { return (
(
)} /> (
)} />
) } ================================================ FILE: web/src/components/features/check/components/form-sections/extra-config-section.tsx ================================================ import { Controller, Control } from 'react-hook-form' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/src/components/ui/select' import { Input } from '@/src/components/ui/input' import { Label } from '@/src/components/ui/label' import { Switch } from '@/src/components/ui/switch' import { useCheckTypes } from '@/src/lib/queries/check-queries' import { UI_TEXT } from '../../constants' import type { CheckRequest } from '@/src/types/check' import type { DynamicConfigItem } from '@/src/types/common' export function ExtraConfigSection({ control }: { control: Control }) { const { data: checkTypeConfigs = {}, isLoading } = useCheckTypes() const checkTypes = Object.keys(checkTypeConfigs) const isConfigFieldEmpty = (configType: string, value: unknown): boolean => { if (configType === 'boolean') return false return value === undefined || value === '' || (typeof value === 'string' && value.trim() === '') } const renderConfigField = (config: DynamicConfigItem) => { return ( { if (field.value === undefined || field.value === null || field.value === '') { if (config.type === 'boolean') { field.onChange(config.value === 'true') } else if (config.type === 'number') { field.onChange(Number(config.value) || 0) } else { field.onChange(config.value || '') } } const isEmpty = isConfigFieldEmpty(config.type, field.value) const showError = config.require && isEmpty switch (config.type) { case 'string': if (config.options) { return ( ) } return ( field.onChange(e.target.value)} className={showError ? 'border-red-500' : ''} /> ) case 'number': return ( field.onChange(Number(e.target.value) || 0)} className={showError ? 'border-red-500' : ''} /> ) case 'boolean': return (
{config.name}
) default: return ( field.onChange(e.target.value)} className={showError ? 'border-red-500' : ''} /> ) } }} /> ) } return (
(
)} /> { const selectedType = field.value const configs = selectedType ? checkTypeConfigs[selectedType] || [] : [] if (!selectedType) { return (
选择检测类型后将显示相关配置项
) } if (isLoading) { return (
加载配置中...
) } if (configs.length === 0) { return
} return (
{configs.map((config) => (
{config.type !== 'boolean' && ( )} {renderConfigField(config)} {config.desc && (

{config.desc}

)} { const isEmpty = isConfigFieldEmpty(config.type, configField.value) return config.require && isEmpty ? (

此字段为必填项

) : <> }} />
))}
) }} />
) } ================================================ FILE: web/src/components/features/check/components/form-sections/index.ts ================================================ export { BasicInfoSection } from './basic-info-section' export { BasicConfigSection } from './basic-config-section' export { NotifyConfig } from './notify-config' export { LogConfig } from './log-config' export { ExtraConfigSection } from './extra-config-section' ================================================ FILE: web/src/components/features/check/components/form-sections/log-config.tsx ================================================ import { Controller, Control } from 'react-hook-form' import { Label } from '@/src/components/ui/label' import { Switch } from '@/src/components/ui/switch' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/src/components/ui/select' import { LOG_LEVELS } from '../../constants' import type { CheckRequest } from '@/src/types/check' export function LogConfig({ control }: { control: Control }) { return (
(
)} /> (
)} />
) } ================================================ FILE: web/src/components/features/check/components/form-sections/notify-config.tsx ================================================ import { Controller, Control, useWatch } from 'react-hook-form' import { Input } from '@/src/components/ui/input' import { Label } from '@/src/components/ui/label' import { Switch } from '@/src/components/ui/switch' import type { CheckRequest } from '@/src/types/check' export function NotifyConfig({ control }: { control: Control }) { const notifyEnabled = useWatch({ control, name: "task.notify", defaultValue: false }) return (
(
)} /> {notifyEnabled && ( (
field.onChange(parseInt(e.target.value) || 1)} min="1" />
)} /> )}
) } ================================================ FILE: web/src/components/features/check/components/index.ts ================================================ export { CheckPage } from './check-page' ================================================ FILE: web/src/components/features/check/constants/index.ts ================================================ export const CHECK_CONSTANTS = { DEFAULT_TIMEOUT: 30, MIN_TIMEOUT: 1, MAX_TIMEOUT: 300, DEFAULT_CRON: "0 */5 * * *", DEFAULT_NOTIFY_CHANNEL: 1, DEFAULT_LOG_LEVEL: "info", } as const export const LOG_LEVELS = [ { value: 'debug', label: 'Debug' }, { value: 'info', label: 'Info' }, { value: 'warn', label: 'Warn' }, { value: 'error', label: 'Error' }, ] as const export const CHECK_STATUS_OPTIONS = [ { value: 'idle', label: '空闲' }, { value: 'running', label: '运行中' }, { value: 'success', label: '成功' }, { value: 'failed', label: '失败' }, ] as const export const FORM_VALIDATION = { NAME_REQUIRED: '任务名称不能为空', TYPE_REQUIRED: '请选择检测类型', TIMEOUT_RANGE: '超时时间必须在1-300秒之间', CRON_INVALID: '请输入有效的Cron表达式', NOTIFY_CHANNEL_MIN: '通知渠道必须大于0', } as const export const UI_TEXT = { CREATE_CHECK: '添加检测', EDIT_CHECK: '编辑检测任务', UPDATE: '更新', CREATE: '创建', CANCEL: '取消', DELETE: '删除', RUN: '运行', LOADING: '加载中...', NO_DATA: '暂无数据', CONFIRM_DELETE: '确认删除', DELETE_CONFIRM_MESSAGE: '您确定要删除检测任务 "{name}" 吗?此操作无法撤销。', RUN_SUCCESS: '运行成功', RUN_FAILED: '运行失败', CREATE_SUCCESS: '创建成功', UPDATE_SUCCESS: '更新成功', DELETE_SUCCESS: '删除成功', CREATE_FAILED: '创建失败', UPDATE_FAILED: '更新失败', DELETE_FAILED: '删除失败', LOAD_TYPES_FAILED: '加载失败', LOAD_CONFIG_FAILED: '加载失败', } as const export const CRON_PRESETS = [ { label: '每5分钟', value: '0 */5 * * *' }, { label: '每10分钟', value: '0 */10 * * *' }, { label: '每30分钟', value: '0 */30 * * *' }, { label: '每小时', value: '0 0 * * *' }, { label: '每天', value: '0 0 0 * *' }, { label: '每周', value: '0 0 0 * 0' }, ] as const ================================================ FILE: web/src/components/features/check/hooks/index.ts ================================================ export { useCheckForm } from './useCheckForm' ================================================ FILE: web/src/components/features/check/hooks/useCheckForm.ts ================================================ import { useForm } from 'react-hook-form' import { useEffect, useMemo } from 'react' import { toast } from 'sonner' import { useCheckTypes, useCreateCheck, useUpdateCheck } from '@/src/lib/queries/check-queries' import { createDefaultCheckData, validateCheckForm } from '../utils' import { UI_TEXT } from '../constants' import type { CheckRequest } from '@/src/types/check' interface UseCheckFormProps { initialData?: CheckRequest | undefined editingCheckId?: number | undefined onSuccess?: () => void isOpen?: boolean } export function useCheckForm({ initialData, editingCheckId, onSuccess, isOpen = true }: UseCheckFormProps = {}) { const { data: checkTypeConfigs = {}, isLoading: isLoadingConfigs } = useCheckTypes() const createCheckMutation = useCreateCheck() const updateCheckMutation = useUpdateCheck() const defaultData = useMemo(() => createDefaultCheckData(), []) const form = useForm({ defaultValues: initialData || defaultData }) const { handleSubmit, reset, watch } = form useEffect(() => { if (isOpen) { reset(initialData || defaultData) } }, [initialData, reset, defaultData, isOpen]) const onSubmit = async (data: CheckRequest) => { const validation = validateCheckForm(data) if (!validation.isValid) { validation.errors.forEach(error => toast.error(error)) return } try { const submitData: CheckRequest = { ...data, } if (editingCheckId) { await updateCheckMutation.mutateAsync({ id: editingCheckId, data: submitData }) toast.success(UI_TEXT.UPDATE_SUCCESS) } else { await createCheckMutation.mutateAsync(submitData) toast.success(UI_TEXT.CREATE_SUCCESS) } onSuccess?.() } catch (error) { const errorMessage = editingCheckId ? UI_TEXT.UPDATE_FAILED : UI_TEXT.CREATE_FAILED toast.error(errorMessage) console.error('Failed to save check:', error) } } return { form, onSubmit: handleSubmit(onSubmit), watch, isEditing: !!editingCheckId, checkTypeConfigs, isLoadingConfigs, } } ================================================ FILE: web/src/components/features/check/index.ts ================================================ export { CheckPage } from './components/check-page' ================================================ FILE: web/src/components/features/check/utils/index.ts ================================================ import { CHECK_CONSTANTS, FORM_VALIDATION } from '../constants' import { validateCronExpr } from '@/src/utils' import type { CheckRequest, CheckResponse } from '@/src/types/check' export function createDefaultCheckData(): CheckRequest { return { name: '', enable: true, task: { type: '', timeout: CHECK_CONSTANTS.DEFAULT_TIMEOUT, cron_expr: CHECK_CONSTANTS.DEFAULT_CRON, notify: false, notify_channel: CHECK_CONSTANTS.DEFAULT_NOTIFY_CHANNEL, log_write_file: false, log_level: CHECK_CONSTANTS.DEFAULT_LOG_LEVEL, sub_id: [], }, config: {}, } } export function validateCheckForm(formData: CheckRequest): { isValid: boolean; errors: string[] } { const errors: string[] = [] if (!formData.name.trim()) { errors.push(FORM_VALIDATION.NAME_REQUIRED) } if (!formData.task.type) { errors.push(FORM_VALIDATION.TYPE_REQUIRED) } if (formData.task.timeout < CHECK_CONSTANTS.MIN_TIMEOUT || formData.task.timeout > CHECK_CONSTANTS.MAX_TIMEOUT) { errors.push(FORM_VALIDATION.TIMEOUT_RANGE) } if (!validateCronExpr(formData.task.cron_expr)) { errors.push(FORM_VALIDATION.CRON_INVALID) } if (formData.task.notify_channel < 1) { errors.push(FORM_VALIDATION.NOTIFY_CHANNEL_MIN) } return { isValid: errors.length === 0, errors } } export function convertCheckResponseToRequest(check: CheckResponse): CheckRequest { return { name: check.name, enable: check.enable, task: { type: check.task?.type || '', timeout: check.task?.timeout || CHECK_CONSTANTS.DEFAULT_TIMEOUT, cron_expr: check.task?.cron_expr || CHECK_CONSTANTS.DEFAULT_CRON, notify: check.task?.notify ?? true, notify_channel: check.task?.notify_channel || CHECK_CONSTANTS.DEFAULT_NOTIFY_CHANNEL, log_write_file: check.task?.log_write_file ?? true, log_level: check.task?.log_level || CHECK_CONSTANTS.DEFAULT_LOG_LEVEL, sub_id: check.task?.sub_id || [], }, config: check.config || {}, } } ================================================ FILE: web/src/components/features/home/dashboard.tsx ================================================ "use client" import { Card, CardContent, CardHeader, CardTitle } from "@/src/components/ui/card" import { Construction } from "lucide-react" export function DashboardPage() { return (
仪表盘正在开发中

我们正在努力完善仪表盘功能,敬请期待!

您可以通过侧边栏访问其他功能模块

) } ================================================ FILE: web/src/components/features/index.ts ================================================ export { SettingsDialog } from './settings/settings' export { DashboardPage } from './home/dashboard' export { StoragePage } from './storage/storage' export { CheckPage } from './check' export { SharePage } from './share/components/share-page' export { NotifyPage } from './notify' export { LoginPage } from './login' export { SubPage } from './sub' export { SystemUpdateDialog } from './system-update' ================================================ FILE: web/src/components/features/login/index.ts ================================================ export { LoginPage } from './login-page' export { LoginForm } from './login-form' ================================================ FILE: web/src/components/features/login/login-form.tsx ================================================ "use client" import { useState } from "react" import { cn } from "@/src/utils" import { Button } from "@/src/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/src/components/ui/card" import { Input } from "@/src/components/ui/input" import { Label } from "@/src/components/ui/label" import { ApiError } from "@/src/lib/api/client" import { Spinner } from "@/src/components/ui/loading" import { useAuth } from "@/src/components/providers" export function LoginForm({ className, ...props }: React.ComponentProps<"div">) { const [username, setUsername] = useState("") const [password, setPassword] = useState("") const [error, setError] = useState("") const { login, isLoading } = useAuth() const getErrorMessage = (error: unknown): string => { if (error instanceof ApiError) { switch (error.code) { case 401: return "用户名或密码错误" case 429: return "登录尝试过于频繁,请稍后再试" case 500: return "服务器错误,请稍后再试" default: return "登录失败,请检查网络连接" } } if (error instanceof Error && error.message.includes('fetch')) { return "网络连接失败,请检查网络设置" } return "登录失败,请稍后再试" } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError("") if (!username.trim() || !password) { setError("请输入用户名和密码") return } try { await login(username.trim(), password) } catch (err) { setError(getErrorMessage(err)) } } return (
登录到您的账户 输入您的用户名和密码来登录
{error && (
{error}
)}
setUsername(e.target.value)} disabled={isLoading} required />
setPassword(e.target.value)} disabled={isLoading} required />
) } ================================================ FILE: web/src/components/features/login/login-page.tsx ================================================ import { LoginForm } from "./login-form" export function LoginPage() { return (
) } ================================================ FILE: web/src/components/features/notify/components/notify-form.tsx ================================================ import { Button } from "@/src/components/ui/button" import { Input } from "@/src/components/ui/input" import { Label } from "@/src/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/src/components/ui/select" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/src/components/ui/dialog" import { DynamicConfigForm } from "@/src/components/shared/dynamic-config-form" import type { DynamicConfigItem, NotifyResponse, NotifyRequest } from "@/src/types" interface NotifyFormProps { formData: NotifyRequest editingNotify: NotifyResponse | null isDialogOpen: boolean notifyChannels: string[] channelConfigs: Record isLoadingChannels: boolean isLoadingConfigs: boolean updateFormField: (field: keyof NotifyRequest, value: string) => void updateConfigField: (field: string, value: string | boolean | number) => void handleChannelChange: (channel: string) => void handleSubmit: (e: React.FormEvent) => void onOpenChange: (open: boolean) => void } export function NotifyForm({ formData, editingNotify, isDialogOpen, notifyChannels, channelConfigs, isLoadingChannels, isLoadingConfigs, updateFormField, updateConfigField, handleChannelChange, handleSubmit, onOpenChange }: NotifyFormProps) { const currentConfigs = formData.type ? channelConfigs[formData.type] || [] : [] return ( {editingNotify ? '编辑通知配置' : '创建通知配置'}
updateFormField('name', e.target.value)} placeholder="请输入通知名称" required />
{formData.type && ( )}
) } ================================================ FILE: web/src/components/features/notify/components/notify-list.tsx ================================================ import { Button } from "@/src/components/ui/button" import { Table, TableBody, TableCell, TableRow } from "@/src/components/ui/table" import { Card, CardContent } from "@/src/components/ui/card" import { InlineLoading } from "@/src/components/ui/loading" import type { NotifyResponse } from "@/src/types" import { Play, Edit, Trash2 } from "lucide-react" import { Badge } from "@/src/components/ui/badge" interface NotifyListProps { notifies: NotifyResponse[] isLoading: boolean deletingId: number | null testingId: number | null onEdit: (notify: NotifyResponse) => void onDelete: (id: number, name: string) => void onTest: (notify: NotifyResponse) => void } export function NotifyList({ notifies, isLoading, deletingId, testingId, onEdit, onDelete, onTest }: NotifyListProps) { if (isLoading) { return ( ) } if (notifies.length === 0) { return (
暂无通知配置,点击上方按钮添加第一个通知配置
) } return ( {notifies.sort((a, b) => a.id - b.id).map((notify) => ( {notify.name} {notify.type?.toUpperCase() || 'N/A'} ))}
) } ================================================ FILE: web/src/components/features/notify/components/notify-page.tsx ================================================ import { useState, useCallback, useEffect } from 'react' import { Button } from "@/src/components/ui/button" import { Plus } from "lucide-react" import { api } from '@/src/lib/api/client' import { NotifyForm } from './notify-form' import { NotifyList } from './notify-list' import { useNotifyForm } from '../hooks/useNotifyForm' import { useNotifyOperations } from '../hooks/useNotifyOperations' import type { NotifyResponse } from '@/src/types' export function NotifyPage() { const [notifies, setNotifies] = useState([]) const [isLoading, setIsLoading] = useState(true) const loadNotifies = useCallback(async () => { try { setIsLoading(true) const data = await api.getNotifyList() setNotifies(data) } catch (error) { console.error('Failed to load notifies:', error) } finally { setIsLoading(false) } }, []) useEffect(() => { loadNotifies() }, [loadNotifies]) const { formData, notifyChannels, channelConfigs, isLoadingChannels, isLoadingConfigs, editingNotify, isDialogOpen, updateFormField, updateConfigField, handleChannelChange, handleSubmit, handleEdit, openCreateDialog, closeDialog } = useNotifyForm({ onSuccess: loadNotifies }) const { deletingId, testingId, handleDelete, handleTest } = useNotifyOperations() const handleTestNotify = useCallback((notify: NotifyResponse) => { handleTest({ name: notify.name, type: notify.type, config: notify.config }, notify.id) }, [handleTest]) return (

通知配置

) } ================================================ FILE: web/src/components/features/notify/hooks/useNotifyForm.ts ================================================ import { useState, useCallback } from 'react' import { toast } from 'sonner' import { api } from '@/src/lib/api/client' import type { NotifyRequest, NotifyResponse, DynamicConfigItem } from '@/src/types' const DEFAULT_FORM_DATA: NotifyRequest = { name: '', type: '', config: {} } interface UseNotifyFormProps { onSuccess: () => void } export function useNotifyForm({ onSuccess }: UseNotifyFormProps) { const [formData, setFormData] = useState(DEFAULT_FORM_DATA) const [notifyChannels, setNotifyChannels] = useState([]) const [channelConfigs, setChannelConfigs] = useState>({}) const [isLoadingChannels, setIsLoadingChannels] = useState(false) const [isLoadingConfigs, setIsLoadingConfigs] = useState(false) const [editingNotify, setEditingNotify] = useState(null) const [isDialogOpen, setIsDialogOpen] = useState(false) const loadNotifyChannels = useCallback(async () => { try { setIsLoadingChannels(true) const channels = await api.getNotifyChannels() setNotifyChannels(channels) } catch (error) { console.error('Failed to load notify channels:', error) toast.error('加载通知渠道失败') } finally { setIsLoadingChannels(false) } }, []) const loadChannelConfig = useCallback(async (channel: string) => { try { setIsLoadingConfigs(true) const configs = await api.getNotifyChannelConfig(channel) if (Array.isArray(configs)) { setChannelConfigs(prev => ({ ...prev, [channel]: configs })) } } catch (error) { console.error('Failed to load channel config:', error) toast.error('加载渠道配置失败') } finally { setIsLoadingConfigs(false) } }, []) const handleChannelChange = useCallback(async (channel: string) => { setFormData(prev => ({ ...prev, type: channel, config: {} })) if (channel && !channelConfigs[channel]) { await loadChannelConfig(channel) } }, [channelConfigs, loadChannelConfig]) const updateFormField = useCallback((field: keyof NotifyRequest, value: string) => { setFormData(prev => ({ ...prev, [field]: value })) }, []) const updateConfigField = useCallback((field: string, value: string | boolean | number) => { setFormData(prev => ({ ...prev, config: { ...prev.config, [field]: value } })) }, []) const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault() if (!formData.name.trim()) { toast.error('请输入通知名称') return } if (!formData.type) { toast.error('请选择通知渠道') return } try { // 处理配置数据,将空值替换为 default 值 const processedConfig = { ...formData.config } const configs = channelConfigs[formData.type] || [] configs.forEach(config => { const value = processedConfig[config.key] // 如果值为空且有 default 值,则使用 default 值 if ((value === undefined || value === '' || value === null) && config.value) { processedConfig[config.key] = config.value } }) const requestData: NotifyRequest = { name: formData.name.trim(), type: formData.type, config: processedConfig } if (editingNotify) { await api.updateNotify(editingNotify.id, requestData) toast.success('通知配置更新成功') } else { await api.createNotify(requestData) toast.success('通知配置创建成功') } setFormData(DEFAULT_FORM_DATA) setEditingNotify(null) setIsDialogOpen(false) onSuccess() } catch (error) { console.error('Failed to submit notify form:', error) toast.error(editingNotify ? '更新通知配置失败' : '创建通知配置失败') } }, [formData, editingNotify, onSuccess, channelConfigs]) const handleEdit = useCallback((notify: NotifyResponse) => { setFormData({ name: notify.name, type: notify.type, config: notify.config }) setEditingNotify(notify) setIsDialogOpen(true) // 确保渠道配置已加载 if (notify.type && !channelConfigs[notify.type]) { loadChannelConfig(notify.type) } }, [channelConfigs, loadChannelConfig]) const openCreateDialog = useCallback(async () => { if (notifyChannels.length === 0) { await loadNotifyChannels() } setFormData(DEFAULT_FORM_DATA) setEditingNotify(null) setIsDialogOpen(true) }, [notifyChannels.length, loadNotifyChannels]) const closeDialog = useCallback(() => { setFormData(DEFAULT_FORM_DATA) setEditingNotify(null) setIsDialogOpen(false) }, []) return { formData, notifyChannels, channelConfigs, isLoadingChannels, isLoadingConfigs, editingNotify, isDialogOpen, updateFormField, updateConfigField, handleChannelChange, handleSubmit, handleEdit, openCreateDialog, closeDialog } } ================================================ FILE: web/src/components/features/notify/hooks/useNotifyOperations.ts ================================================ import { useState, useCallback } from 'react' import { toast } from 'sonner' import { useAlert } from '@/src/components/providers' import { api } from '@/src/lib/api/client' import type { NotifyRequest } from '@/src/types' export function useNotifyOperations() { const { confirm } = useAlert() const [testingId, setTestingId] = useState(null) const [deletingId, setDeletingId] = useState(null) const handleTest = useCallback(async (notify: NotifyRequest, id?: number) => { try { setTestingId(id || 0) await api.testNotify(notify) toast.success('通知测试成功') } catch (error) { console.error('Failed to test notify:', error) toast.error('通知测试失败') } finally { setTestingId(null) } }, []) const handleDelete = useCallback(async (id: number, name: string) => { const confirmed = await confirm({ title: '删除通知', description: `确定要删除通知配置 "${name}" 吗?`, confirmText: '删除', cancelText: '取消', variant: 'destructive' }) if (confirmed) { try { setDeletingId(id) await api.deleteNotify(id) toast.success('删除成功') } catch (error) { console.error('Failed to delete notify:', error) toast.error('删除失败') } finally { setDeletingId(null) } } }, [confirm]) return { deletingId, testingId, handleDelete, handleTest } } ================================================ FILE: web/src/components/features/notify/index.ts ================================================ export { NotifyPage } from './components/notify-page' export { NotifyForm } from './components/notify-form' export { NotifyList } from './components/notify-list' export { useNotifyForm } from './hooks/useNotifyForm' export { useNotifyOperations } from './hooks/useNotifyOperations' ================================================ FILE: web/src/components/features/profile/ProfileDesktopNavButton.tsx ================================================ import * as React from "react" import { cn } from "@/src/utils" interface ProfileDesktopNavButtonProps { tabId: string activeTab: string onTabChange: (id: string) => void children: React.ReactNode } export function ProfileDesktopNavButton({ tabId, activeTab, onTabChange, children }: ProfileDesktopNavButtonProps) { const handleClick = (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() onTabChange(tabId) } return ( ) } ================================================ FILE: web/src/components/features/profile/ProfileDialog.tsx ================================================ "use client" import { useState, useEffect, useCallback } from "react" import { useForm } from "react-hook-form" import { toast } from "sonner" import { Dialog, DialogContent, } from "@/src/components/ui/dialog" import { Button } from "@/src/components/ui/button" import { Input } from "@/src/components/ui/input" import { Label } from "@/src/components/ui/label" import { ProfileLayout } from "./ProfileLayout" import { InlineLoading } from "@/src/components/ui/loading" import { api } from "@/src/lib/api/client" import { useAuth } from "@/src/components/providers" interface ProfileDialogProps { open: boolean onOpenChange: (open: boolean) => void } interface FormData { username: string oldPassword: string newPassword: string confirmPassword: string } export function ProfileDialog({ open, onOpenChange }: ProfileDialogProps) { const { user, logout, updateUser } = useAuth() const [activeTab, setActiveTab] = useState("profile") const [isSubmitting, setIsSubmitting] = useState(false) const form = useForm({ mode: 'onChange', defaultValues: { username: user?.username || "", oldPassword: "", newPassword: "", confirmPassword: "" } }) useEffect(() => { if (open && user) { form.reset({ username: user.username, oldPassword: "", newPassword: "", confirmPassword: "" }) } }, [open, user]) const handleUpdateUsername = useCallback(async (data: FormData) => { if (!data.username.trim()) { toast.error("用户名不能为空") return } if (data.username === user?.username) { toast.error("新用户名不能与当前用户名相同") return } setIsSubmitting(true) try { await api.updateUsername({ username: data.username }) updateUser({ ...user!, username: data.username }) toast.success("用户名修改成功") onOpenChange(false) } catch (error: any) { toast.error(error.message || "用户名修改失败") } finally { setIsSubmitting(false) } }, [user, updateUser, onOpenChange]) const handleChangePassword = useCallback(async (data: FormData) => { if (!data.oldPassword || !data.newPassword) { toast.error("请填写完整密码信息") return } if (data.newPassword !== data.confirmPassword) { toast.error("两次输入的新密码不一致") return } if (data.newPassword.length < 6) { toast.error("新密码长度至少为6位") return } setIsSubmitting(true) try { await api.changePassword({ username: user!.username, old_password: data.oldPassword, new_password: data.newPassword }) toast.success("密码修改成功,请重新登录") onOpenChange(false) // 密码修改成功后调用登出 setTimeout(() => { logout() }, 1000) } catch (error: any) { toast.error(error.message || "密码修改失败") } finally { setIsSubmitting(false) } }, [user, logout, onOpenChange]) const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault() const data = form.getValues() if (activeTab === "profile") { await handleUpdateUsername(data) } else if (activeTab === "password") { await handleChangePassword(data) } }, [activeTab, form, handleUpdateUsername, handleChangePassword]) const renderContent = () => { switch (activeTab) { case "profile": return (
{form.formState.errors.username && (

用户名不能为空

)}
) case "password": return (
{form.formState.errors.newPassword && (

密码长度至少为6位

)}
value === form.getValues("newPassword") || "两次输入的密码不一致" })} placeholder="请再次输入新密码" /> {form.formState.errors.confirmPassword && (

{form.formState.errors.confirmPassword.message}

)}
) default: return null } } const renderActions = (isMobile?: boolean) => { const isDirty = form.formState.isDirty const canSubmit = activeTab === "profile" ? isDirty && form.getValues("username").trim() !== user?.username : isDirty && form.getValues("newPassword") === form.getValues("confirmPassword") && form.getValues("newPassword").length >= 6 return ( <> ) } return ( {renderContent()} ) } ================================================ FILE: web/src/components/features/profile/ProfileLayout.tsx ================================================ import * as React from "react" import { ProfileNavButton } from "./ProfileNavButton" import { ProfileDesktopNavButton } from "./ProfileDesktopNavButton" interface ProfileLayoutProps { activeTab: string onTabChange: (id: string) => void children: React.ReactNode renderActions: (isMobile?: boolean) => React.ReactNode onSubmit: (e: React.FormEvent) => void } export function ProfileLayout({ activeTab, onTabChange, children, renderActions, onSubmit }: ProfileLayoutProps) { return (

个人资料

个人资料 修改密码
{children}
{renderActions(true)}

个人资料

{children}
{renderActions(false)}
) } ================================================ FILE: web/src/components/features/profile/ProfileNavButton.tsx ================================================ import * as React from "react" import { cn } from "@/src/utils" interface ProfileNavButtonProps { tabId: string activeTab: string onTabChange: (id: string) => void children: React.ReactNode } export function ProfileNavButton({ tabId, activeTab, onTabChange, children }: ProfileNavButtonProps) { const handleClick = (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() onTabChange(tabId) } return ( ) } ================================================ FILE: web/src/components/features/profile/index.ts ================================================ export { ProfileDialog } from './ProfileDialog' ================================================ FILE: web/src/components/features/settings/SettingsActions.tsx ================================================ import * as React from "react" import { Button } from "@/src/components/ui/button" interface SettingsActionsProps { onCancel: () => void isMobile?: boolean hasChanges?: boolean } export function SettingsActions({ onCancel, isMobile, hasChanges }: SettingsActionsProps) { return ( <> ) } ================================================ FILE: web/src/components/features/settings/SettingsLayout.tsx ================================================ import * as React from "react" import { cn } from "@/src/utils" interface SettingsLayoutProps { nav: Array<{ name: string; id: string }> activeTab: string onTabChange: (id: string) => void children: React.ReactNode renderActions: (isMobile: boolean) => React.ReactNode onSubmit: (e: React.FormEvent) => void } export function SettingsLayout({ nav, activeTab, onTabChange, children, renderActions, onSubmit }: SettingsLayoutProps) { const handleTabClick = (e: React.MouseEvent, itemId: string) => { e.preventDefault() e.stopPropagation() onTabChange(itemId) } return (

设置

{nav.map((item) => ( ))}
{children}
{renderActions(true)}

设置

{children}
{renderActions(false)}
) } ================================================ FILE: web/src/components/features/settings/sections/NodeSettingsSection.tsx ================================================ import { Controller, useWatch, type Control } from "react-hook-form" import type { FormValues } from "@/src/types/setting" import { BooleanSettingField } from "./fields/BooleanSettingField" import { MultiSelectSettingField } from "./fields/MultiSelectSettingField" import { NumberSettingField } from "./fields/NumberSettingField" import { TextSettingField } from "./fields/TextSettingField" import { PROTOCOL_OPTIONS } from "@/src/constant/protocols" import { NODE_POOL_SIZE, NODE_PROTOCOL_FILTER, NODE_PROTOCOL_FILTER_ENABLE, NODE_PROTOCOL_FILTER_MODE, NODE_TEST_TIMEOUT, NODE_TEST_URL, } from "@/src/constant/settings-keys" export function NodeSettingsSection({ control }: { control: Control }) { const protocolFilterEnabled = Boolean(useWatch({ control, name: NODE_PROTOCOL_FILTER_ENABLE })) return (
( )} /> ( )} /> ( )} /> ( )} /> {protocolFilterEnabled && ( <> ( )} /> ( )} /> )}
) } ================================================ FILE: web/src/components/features/settings/sections/NotifySettingsSection.tsx ================================================ import { Controller, type Control } from "react-hook-form" import type { FormValues } from "@/src/types/setting" import { NumberSettingField } from "./fields/NumberSettingField" import { NOTIFY_ID, NOTIFY_OPERATION } from "@/src/constant/settings-keys" export function NotifySettingsSection({ control }: { control: Control }) { return (
( )} /> ( )} />
) } ================================================ FILE: web/src/components/features/settings/sections/SystemSettingsSection.tsx ================================================ import { Controller, useWatch, type Control } from "react-hook-form" import type { FormValues } from "@/src/types/setting" import { LOG_RETENTION_DAYS, PROXY_ENABLE, PROXY_URL, SUBCONV_URL, SUBCONV_URL_PROXY, SUB_DISABLE_AUTO, } from "@/src/constant/settings-keys" import { BooleanSettingField } from "./fields/BooleanSettingField" import { NumberSettingField } from "./fields/NumberSettingField" import { TextSettingField } from "./fields/TextSettingField" export function SystemSettingsSection({ control }: { control: Control }) { const proxyEnabled = Boolean(useWatch({ control, name: PROXY_ENABLE })) return (
( )} /> {proxyEnabled && ( ( )} /> )} ( )} /> ( 使用{" "} SubWorker {" "} 或已有的 SubStore 地址, 示例: http://ip:port/xxxxxxx } value={String(field.value ?? "")} onChange={field.onChange} /> )} /> ( )} /> ( )} />
) } ================================================ FILE: web/src/components/features/settings/sections/TaskSettingsSection.tsx ================================================ import { Controller, type Control } from "react-hook-form" import type { FormValues } from "@/src/types/setting" import { NumberSettingField } from "./fields/NumberSettingField" import { TASK_MAX_RETRY, TASK_MAX_THREAD, TASK_MAX_TIMEOUT } from "@/src/constant/settings-keys" export function TaskSettingsSection({ control }: { control: Control }) { return (
( )} /> ( )} /> ( )} />
) } ================================================ FILE: web/src/components/features/settings/sections/fields/BooleanSettingField.tsx ================================================ import type { BaseSettingProps } from "./types" import { SettingCard } from "./SettingCard" import { Switch } from "@/src/components/ui/switch" interface BooleanSettingFieldProps extends BaseSettingProps { checked: boolean onCheckedChange: (checked: boolean) => void } export function BooleanSettingField({ title, description, checked, onCheckedChange, }: BooleanSettingFieldProps) { return ( } actionAlignment="center" /> ) } ================================================ FILE: web/src/components/features/settings/sections/fields/MultiSelectSettingField.tsx ================================================ import { Badge } from "@/src/components/ui/badge" import type { BaseSettingProps } from "./types" import { SettingCard } from "./SettingCard" type MultiSelectSettingFieldProps = BaseSettingProps & { value: string[] options: string[] onChange: (value: string[]) => void } export function MultiSelectSettingField({ title, description, value, options, onChange, }: MultiSelectSettingFieldProps) { const toggleValue = (option: string) => { const exists = value.includes(option) const nextValues = exists ? value.filter((item) => item !== option) : [...value, option] const ordered = options.filter((item) => nextValues.includes(item)) onChange(ordered) } return (
{options.length === 0 && (

暂无可选项

)} {options.map((option) => { const active = value.includes(option) return ( toggleValue(option)} > {option} {active ? "×" : "+"} ) })}
) } ================================================ FILE: web/src/components/features/settings/sections/fields/NumberSettingField.tsx ================================================ import { Input } from "@/src/components/ui/input" import type { BaseSettingProps } from "./types" import { SettingCard } from "./SettingCard" interface NumberSettingFieldProps extends BaseSettingProps { value: unknown onChange: (value: number | string) => void min?: number } export function NumberSettingField({ title, description, value, onChange, min, }: NumberSettingFieldProps) { const inputValue = getNumberInputValue(value) return ( { const nextValue = event.target.value if (nextValue === "") { onChange("") return } onChange(Number(nextValue)) }} className="h-10" /> ) } const getNumberInputValue = (value: unknown): string | number => { if (typeof value === "number") { return Number.isNaN(value) ? "" : value } if (typeof value === "string") { return value } return "" } ================================================ FILE: web/src/components/features/settings/sections/fields/SelectSettingField.tsx ================================================ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/src/components/ui/select" import type { BaseSettingProps } from "./types" import { SettingCard } from "./SettingCard" type SelectSettingFieldProps = BaseSettingProps & { value: string options: Array<{ label: string; value: string }> onChange: (value: string) => void placeholder?: string } export function SelectSettingField({ title, description, value, options, onChange, placeholder = "选择选项", }: SelectSettingFieldProps) { return ( ) } ================================================ FILE: web/src/components/features/settings/sections/fields/SettingCard.tsx ================================================ import type { ReactNode } from "react" type SettingCardProps = { title: string description?: ReactNode action?: ReactNode actionAlignment?: 'center' | 'start' children?: ReactNode } export function SettingCard({ title, description, action, actionAlignment = 'start', children }: SettingCardProps) { const headerAlignment = action ? (actionAlignment === 'center' ? 'items-center' : 'items-start') : 'items-start' return (

{title}

{description && (

{description}

)}
{action ? (
{action}
) : null}
{children}
) } ================================================ FILE: web/src/components/features/settings/sections/fields/TextSettingField.tsx ================================================ import { Input } from "@/src/components/ui/input" import type { BaseSettingProps } from "./types" import { SettingCard } from "./SettingCard" type TextSettingFieldProps = BaseSettingProps & { value: string onChange: (value: string) => void placeholder?: string inputType?: React.ComponentProps["type"] } export function TextSettingField({ title, description, value, onChange, placeholder, inputType = "text", }: TextSettingFieldProps) { return ( onChange(event.target.value)} placeholder={placeholder} className="h-10" /> ) } ================================================ FILE: web/src/components/features/settings/sections/fields/types.ts ================================================ import type { ReactNode } from "react" export type BaseSettingProps = { title: string description?: ReactNode } ================================================ FILE: web/src/components/features/settings/sections/index.ts ================================================ import type { ComponentType } from "react" import type { Control } from "react-hook-form" import type { FormValues } from "@/src/types/setting" import { NotifySettingsSection } from "./NotifySettingsSection" import { NodeSettingsSection } from "./NodeSettingsSection" import { SystemSettingsSection } from "./SystemSettingsSection" import { TaskSettingsSection } from "./TaskSettingsSection" type SectionComponent = ComponentType<{ control: Control }> interface SettingsSectionDefinition { id: string label: string Component: SectionComponent } export const SETTINGS_SECTIONS: SettingsSectionDefinition[] = [ { id: "system-config", label: "系统配置", Component: SystemSettingsSection, }, { id: "node-config", label: "节点配置", Component: NodeSettingsSection, }, { id: "task-config", label: "任务配置", Component: TaskSettingsSection, }, { id: "notify-config", label: "通知配置", Component: NotifySettingsSection, }, ] ================================================ FILE: web/src/components/features/settings/settings.tsx ================================================ "use client" import { useState, useMemo, useCallback, useEffect } from "react" import { useForm } from "react-hook-form" import { Dialog, DialogContent, } from "@/src/components/ui/dialog" import { InlineLoading } from "@/src/components/ui/loading" import { useSettings, useUpdateSettings } from "@/src/lib/queries/setting-queries" import { SettingsLayout } from "./SettingsLayout" import { SettingsActions } from "./SettingsActions" import type { FormValues, Setting } from "@/src/types/setting" import { SETTINGS_SECTIONS } from "./sections" import { cloneFormValues, mapSettingsToFormValues } from "./utils/value-mappers" interface SettingsDialogProps { open: boolean onOpenChange: (open: boolean) => void } export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { const [activeTab, setActiveTab] = useState(() => SETTINGS_SECTIONS[0]?.id ?? "") const updateSettingsMutation = useUpdateSettings() const { data: backendSettings, isLoading, error } = useSettings() const form = useForm({ mode: 'onChange', shouldUnregister: false, shouldFocusError: true, defaultValues: {}, }) const { dirtyFields, isDirty } = form.formState useEffect(() => { if (backendSettings === undefined || isDirty) { return } const mappedValues = mapSettingsToFormValues(backendSettings) const currentValues = form.getValues() if (areFormValuesEqual(currentValues, mappedValues)) { return } form.reset(mappedValues, { keepDirty: false, keepDirtyValues: false, keepErrors: false, keepTouched: false, keepSubmitCount: false, }) }, [backendSettings, form, isDirty]) const nav = useMemo( () => SETTINGS_SECTIONS.map((section) => ({ id: section.id, name: section.label, })), [] ) const currentSection = useMemo(() => { if (!SETTINGS_SECTIONS.length) return null return ( SETTINGS_SECTIONS.find((section) => section.id === activeTab) ?? SETTINGS_SECTIONS[0] ) }, [activeTab]) const hasChanges = isDirty const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault() if (!hasChanges) { onOpenChange(false) return } const changes: Setting[] = [] const formValues = form.getValues() const dirtyKeys = Object.keys(dirtyFields) as Array dirtyKeys.forEach((key) => { if (!dirtyFields[key]) return const value = formValues[key] if (value === undefined) return let stringValue: string if (Array.isArray(value)) { stringValue = value.join(',') } else if (typeof value === 'boolean') { stringValue = value ? 'true' : 'false' } else { stringValue = value === null ? '' : String(value) } changes.push({ key: String(key), value: stringValue }) }) if (changes.length > 0) { try { await updateSettingsMutation.mutateAsync(changes) const nextValues = cloneFormValues(formValues) form.reset(nextValues, { keepDirty: false, keepDirtyValues: false, keepErrors: false, keepTouched: false, keepSubmitCount: false, }) onOpenChange(false) } catch (err) { console.error('Failed to save settings:', err) } } else { onOpenChange(false) } }, [hasChanges, dirtyFields, form, updateSettingsMutation, onOpenChange]) const renderContent = useMemo(() => { if (isLoading) { return (
) } if (error) { return (
设置加载失败 {error instanceof Error ? error.message : '请稍后重试'}
) } if (!currentSection) { return (
暂无可用的设置项
) } const SectionComponent = currentSection.Component return }, [currentSection, form.control, error, isLoading]) return ( ( onOpenChange(false)} isMobile={!!isMobile} hasChanges={hasChanges} /> )} > {renderContent} ) } const areFormValuesEqual = (a: FormValues, b: FormValues): boolean => { const aKeys = Object.keys(a) const bKeys = Object.keys(b) if (aKeys.length !== bKeys.length) { return false } return aKeys.every((key) => { const aValue = a[key] const bValue = b[key] if (Array.isArray(aValue) && Array.isArray(bValue)) { if (aValue.length !== bValue.length) { return false } return aValue.every((item, index) => item === bValue[index]) } return aValue === bValue }) } ================================================ FILE: web/src/components/features/settings/utils/value-mappers.ts ================================================ import { BOOLEAN_SETTING_KEYS, MULTI_SELECT_SETTING_KEYS, NUMBER_SETTING_KEYS } from "@/src/constant/settings-keys" import type { FormValue, FormValues, Setting } from "@/src/types/setting" const parseBoolean = (value: string | undefined): boolean => { return value?.trim().toLowerCase() === "true" } const parseNumber = (value: string | undefined): number | string => { if (!value?.length) { return "" } const parsed = Number(value) return Number.isNaN(parsed) ? "" : parsed } const parseMultiSelect = (value: string | undefined): string[] => { if (!value) return [] return value .split(",") .map((item) => item.trim()) .filter((item) => item.length > 0) } const parseValue = (key: string, value: string | undefined): FormValue => { if (BOOLEAN_SETTING_KEYS.has(key)) { return parseBoolean(value) } if (NUMBER_SETTING_KEYS.has(key)) { return parseNumber(value) } if (MULTI_SELECT_SETTING_KEYS.has(key)) { return parseMultiSelect(value) } return value ?? "" } export const mapSettingsToFormValues = (settings: Setting[] | undefined): FormValues => { if (!settings || settings.length === 0) { return {} } return settings.reduce((acc, setting) => { if (!setting.key) return acc acc[setting.key] = parseValue(setting.key, setting.value) return acc }, {}) } export const cloneFormValues = (values: FormValues): FormValues => { const clonedEntries = Object.entries(values).map(([key, value]) => { if (Array.isArray(value)) { return [key, [...value]] as const } return [key, value] as const }) return Object.fromEntries(clonedEntries) as FormValues } ================================================ FILE: web/src/components/features/share/components/form-sections/alive-status-section.tsx ================================================ import { Controller, Control } from 'react-hook-form' import { Label } from '@/src/components/ui/label' import { Badge } from '@/src/components/ui/badge' interface AliveStatusSectionProps { control: Control | any> fieldName: string } // 根据 bestsub/internal/models/node/node.go 中的常量定义 const ALIVE_STATUS_FLAGS = [ { value: 1, label: '存活', name: 'Alive' }, // 1 << 0 { value: 2, label: '国家', name: 'Country' }, // 1 << 1 { value: 4, label: 'TikTok', name: 'TikTok' }, // 1 << 2 { value: 8, label: 'TikTok IDC', name: 'TikTok IDC' }, // 1 << 3 ] as const export function AliveStatusSection({ control, fieldName }: AliveStatusSectionProps) { return ( (
{ALIVE_STATUS_FLAGS.map(flag => { const currentValue = (field.value as number) || 0 const isSelected = (currentValue & flag.value) !== 0 const handleToggleSelection = (flagValue: number) => { let newValue = currentValue if (isSelected) { // 取消选择:使用异或运算清除该位 newValue = currentValue & ~flagValue } else { // 选择:使用或运算设置该位 newValue = currentValue | flagValue } field.onChange(newValue) } return ( handleToggleSelection(flag.value)} > {flag.label} {isSelected ? "×" : "+"} ) })}

点击状态进行选择/取消选择,支持多选组合

)} /> ) } ================================================ FILE: web/src/components/features/share/components/form-sections/basic-info-section.tsx ================================================ import { Controller, Control } from 'react-hook-form' import { Input } from '@/src/components/ui/input' import { Label } from '@/src/components/ui/label' import { Switch } from '@/src/components/ui/switch' import { FORM_VALIDATION } from '../../constants' import type { ShareRequest } from '@/src/types' interface BasicInfoSectionProps { control: Control } export function BasicInfoSection({ control }: BasicInfoSectionProps) { return (
( )} />
( )} />
( )} />
) } ================================================ FILE: web/src/components/features/share/components/form-sections/config-section.tsx ================================================ import { Controller, Control } from 'react-hook-form' import { Input } from '@/src/components/ui/input' import { Label } from '@/src/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/src/components/ui/select' import { Calendar22 } from '../share-date-pick' import { SUBSCRIPTION_TARGETS } from '../../constants' import type { ShareRequest } from '@/src/types' interface ConfigSectionProps { control: Control } export function ConfigSection({ control }: ConfigSectionProps) { return (
( )} />
( )} />
( field.onChange(parseInt(e.target.value || '0'))} /> )} />
( field.onChange(ts || 0)} /> )} />
) } ================================================ FILE: web/src/components/features/share/components/form-sections/country-section.tsx ================================================ // 此文件由AI生成。如有错误,请手动修正 // This file was generated with AI assistance // Please correct any errors manually import { useState } from 'react' import { Controller, Control } from 'react-hook-form' import { Label } from '@/src/components/ui/label' import { Badge } from '@/src/components/ui/badge' import { Input } from '@/src/components/ui/input' import { Switch } from '@/src/components/ui/switch' interface CountrySectionProps { control: Control | any> fieldName: string } const POPULAR_COUNTRIES = [ { code: 'CN', name: '中国' }, { code: 'US', name: '美国' }, { code: 'JP', name: '日本' }, { code: 'KR', name: '韩国' }, { code: 'HK', name: '香港' }, { code: 'TW', name: '台湾' }, { code: 'SG', name: '新加坡' }, { code: 'GB', name: '英国' }, { code: 'DE', name: '德国' }, { code: 'FR', name: '法国' }, ] as const const ALL_COUNTRIES = [ ...POPULAR_COUNTRIES, { code: 'AD', name: '安道尔' }, { code: 'AE', name: '阿联酋' }, { code: 'AF', name: '阿富汗' }, { code: 'AG', name: '安提瓜和巴布达' }, { code: 'AI', name: '安圭拉' }, { code: 'AL', name: '阿尔巴尼亚' }, { code: 'AM', name: '亚美尼亚' }, { code: 'AO', name: '安哥拉' }, { code: 'AQ', name: '南极洲' }, { code: 'AR', name: '阿根廷' }, { code: 'AS', name: '美属萨摩亚' }, { code: 'AT', name: '奥地利' }, { code: 'AU', name: '澳大利亚' }, { code: 'AW', name: '阿鲁巴' }, { code: 'AZ', name: '阿塞拜疆' }, { code: 'BA', name: '波黑' }, { code: 'BB', name: '巴巴多斯' }, { code: 'BD', name: '孟加拉国' }, { code: 'BE', name: '比利时' }, { code: 'BF', name: '布基纳法索' }, { code: 'BG', name: '保加利亚' }, { code: 'BH', name: '巴林' }, { code: 'BI', name: '布隆迪' }, { code: 'BJ', name: '贝宁' }, { code: 'BL', name: '法属圣巴泰勒米' }, { code: 'BM', name: '百慕大' }, { code: 'BN', name: '文莱' }, { code: 'BO', name: '玻利维亚' }, { code: 'BQ', name: '荷属加勒比区' }, { code: 'BR', name: '巴西' }, { code: 'BS', name: '巴哈马' }, { code: 'BT', name: '不丹' }, { code: 'BV', name: '布维岛' }, { code: 'BW', name: '博茨瓦纳' }, { code: 'BY', name: '白俄罗斯' }, { code: 'BZ', name: '伯利兹' }, { code: 'CA', name: '加拿大' }, { code: 'CC', name: '科科斯(基林)群岛' }, { code: 'CD', name: '刚果(金)' }, { code: 'CF', name: '中非共和国' }, { code: 'CG', name: '刚果(布)' }, { code: 'CH', name: '瑞士' }, { code: 'CI', name: '科特迪瓦' }, { code: 'CK', name: '库克群岛' }, { code: 'CL', name: '智利' }, { code: 'CM', name: '喀麦隆' }, { code: 'CO', name: '哥伦比亚' }, { code: 'CR', name: '哥斯达黎加' }, { code: 'CU', name: '古巴' }, { code: 'CV', name: '佛得角' }, { code: 'CW', name: '库拉索' }, { code: 'CX', name: '圣诞岛' }, { code: 'CY', name: '塞浦路斯' }, { code: 'CZ', name: '捷克' }, { code: 'DJ', name: '吉布提' }, { code: 'DK', name: '丹麦' }, { code: 'DM', name: '多米尼克' }, { code: 'DO', name: '多米尼加' }, { code: 'DZ', name: '阿尔及利亚' }, { code: 'EC', name: '厄瓜多尔' }, { code: 'EE', name: '爱沙尼亚' }, { code: 'EG', name: '埃及' }, { code: 'EH', name: '西撒哈拉' }, { code: 'ER', name: '厄立特里亚' }, { code: 'ES', name: '西班牙' }, { code: 'ET', name: '埃塞俄比亚' }, { code: 'FI', name: '芬兰' }, { code: 'FJ', name: '斐济' }, { code: 'FK', name: '福克兰群岛(马尔维纳斯)' }, { code: 'FM', name: '密克罗尼西亚联邦' }, { code: 'FO', name: '法罗群岛' }, { code: 'GA', name: '加蓬' }, { code: 'GD', name: '格林纳达' }, { code: 'GE', name: '格鲁吉亚' }, { code: 'GF', name: '法属圭亚那' }, { code: 'GG', name: '根西' }, { code: 'GH', name: '加纳' }, { code: 'GI', name: '直布罗陀' }, { code: 'GL', name: '格陵兰' }, { code: 'GM', name: '冈比亚' }, { code: 'GN', name: '几内亚' }, { code: 'GP', name: '瓜德罗普' }, { code: 'GQ', name: '赤道几内亚' }, { code: 'GR', name: '希腊' }, { code: 'GS', name: '南乔治亚岛和南桑威奇群岛' }, { code: 'GT', name: '危地马拉' }, { code: 'GU', name: '关岛' }, { code: 'GW', name: '几内亚比绍' }, { code: 'GY', name: '圭亚那' }, { code: 'HM', name: '赫德岛和麦克唐纳群岛' }, { code: 'HN', name: '洪都拉斯' }, { code: 'HR', name: '克罗地亚' }, { code: 'HT', name: '海地' }, { code: 'HU', name: '匈牙利' }, { code: 'ID', name: '印度尼西亚' }, { code: 'IE', name: '爱尔兰' }, { code: 'IL', name: '以色列' }, { code: 'IM', name: '马恩岛' }, { code: 'IN', name: '印度' }, { code: 'IO', name: '英属印度洋领地' }, { code: 'IQ', name: '伊拉克' }, { code: 'IR', name: '伊朗' }, { code: 'IS', name: '冰岛' }, { code: 'IT', name: '意大利' }, { code: 'JE', name: '泽西' }, { code: 'JM', name: '牙买加' }, { code: 'JO', name: '约旦' }, { code: 'KE', name: '肯尼亚' }, { code: 'KG', name: '吉尔吉斯斯坦' }, { code: 'KH', name: '柬埔寨' }, { code: 'KI', name: '基里巴斯' }, { code: 'KM', name: '科摩罗' }, { code: 'KN', name: '圣基茨和尼维斯' }, { code: 'KP', name: '朝鲜' }, { code: 'KW', name: '科威特' }, { code: 'KY', name: '开曼群岛' }, { code: 'KZ', name: '哈萨克斯坦' }, { code: 'LA', name: '老挝' }, { code: 'LB', name: '黎巴嫩' }, { code: 'LC', name: '圣卢西亚' }, { code: 'LI', name: '列支敦士登' }, { code: 'LK', name: '斯里兰卡' }, { code: 'LR', name: '利比里亚' }, { code: 'LS', name: '莱索托' }, { code: 'LT', name: '立陶宛' }, { code: 'LU', name: '卢森堡' }, { code: 'LV', name: '拉脱维亚' }, { code: 'LY', name: '利比亚' }, { code: 'MA', name: '摩洛哥' }, { code: 'MC', name: '摩纳哥' }, { code: 'MD', name: '摩尔多瓦' }, { code: 'ME', name: '黑山' }, { code: 'MF', name: '法属圣马丁' }, { code: 'MG', name: '马达加斯加' }, { code: 'MH', name: '马绍尔群岛' }, { code: 'MK', name: '北马其顿' }, { code: 'ML', name: '马里' }, { code: 'MM', name: '缅甸' }, { code: 'MN', name: '蒙古' }, { code: 'MO', name: '中国澳门' }, { code: 'MP', name: '北马里亚纳群岛' }, { code: 'MQ', name: '马提尼克' }, { code: 'MR', name: '毛里塔尼亚' }, { code: 'MS', name: '蒙特塞拉特' }, { code: 'MT', name: '马耳他' }, { code: 'MU', name: '毛里求斯' }, { code: 'MV', name: '马尔代夫' }, { code: 'MW', name: '马拉维' }, { code: 'MX', name: '墨西哥' }, { code: 'MY', name: '马来西亚' }, { code: 'MZ', name: '莫桑比克' }, { code: 'NA', name: '纳米比亚' }, { code: 'NC', name: '新喀里多尼亚' }, { code: 'NE', name: '尼日尔' }, { code: 'NF', name: '诺福克岛' }, { code: 'NG', name: '尼日利亚' }, { code: 'NI', name: '尼加拉瓜' }, { code: 'NL', name: '荷兰' }, { code: 'NO', name: '挪威' }, { code: 'NP', name: '尼泊尔' }, { code: 'NR', name: '瑙鲁' }, { code: 'NU', name: '纽埃' }, { code: 'NZ', name: '新西兰' }, { code: 'OM', name: '阿曼' }, { code: 'PA', name: '巴拿马' }, { code: 'PE', name: '秘鲁' }, { code: 'PF', name: '法属波利尼西亚' }, { code: 'PG', name: '巴布亚新几内亚' }, { code: 'PH', name: '菲律宾' }, { code: 'PK', name: '巴基斯坦' }, { code: 'PL', name: '波兰' }, { code: 'PM', name: '圣皮埃尔和密克隆' }, { code: 'PN', name: '皮特凯恩群岛' }, { code: 'PR', name: '波多黎各' }, { code: 'PS', name: '巴勒斯坦国' }, { code: 'PT', name: '葡萄牙' }, { code: 'PW', name: '帕劳' }, { code: 'PY', name: '巴拉圭' }, { code: 'QA', name: '卡塔尔' }, { code: 'RE', name: '留尼汪' }, { code: 'RO', name: '罗马尼亚' }, { code: 'RS', name: '塞尔维亚' }, { code: 'RW', name: '卢旺达' }, { code: 'SA', name: '沙特阿拉伯' }, { code: 'SB', name: '所罗门群岛' }, { code: 'SC', name: '塞舌尔' }, { code: 'SD', name: '苏丹' }, { code: 'SE', name: '瑞典' }, { code: 'SH', name: '圣赫勒拿、阿森松和特里斯坦-达库尼亚' }, { code: 'SI', name: '斯洛文尼亚' }, { code: 'SJ', name: '斯瓦尔巴和扬马延' }, { code: 'SK', name: '斯洛伐克' }, { code: 'SL', name: '塞拉利昂' }, { code: 'SM', name: '圣马力诺' }, { code: 'SN', name: '塞内加尔' }, { code: 'SO', name: '索马里' }, { code: 'SR', name: '苏里南' }, { code: 'SS', name: '南苏丹' }, { code: 'ST', name: '圣多美和普林西比' }, { code: 'SV', name: '萨尔瓦多' }, { code: 'SX', name: '荷属圣马丁' }, { code: 'SY', name: '叙利亚' }, { code: 'SZ', name: '埃斯瓦蒂尼' }, { code: 'TC', name: '特克斯和凯科斯群岛' }, { code: 'TD', name: '乍得' }, { code: 'TF', name: '法属南部领地' }, { code: 'TG', name: '多哥' }, { code: 'TH', name: '泰国' }, { code: 'TJ', name: '塔吉克斯坦' }, { code: 'TK', name: '托克劳' }, { code: 'TL', name: '东帝汶' }, { code: 'TM', name: '土库曼斯坦' }, { code: 'TN', name: '突尼斯' }, { code: 'TO', name: '汤加' }, { code: 'TR', name: '土耳其' }, { code: 'TT', name: '特立尼达和多巴哥' }, { code: 'TV', name: '图瓦卢' }, { code: 'TZ', name: '坦桑尼亚' }, { code: 'UA', name: '乌克兰' }, { code: 'UG', name: '乌干达' }, { code: 'UM', name: '美国本土外小岛屿' }, { code: 'UY', name: '乌拉圭' }, { code: 'UZ', name: '乌兹别克斯坦' }, { code: 'VA', name: '梵蒂冈' }, { code: 'VC', name: '圣文森特和格林纳丁斯' }, { code: 'VE', name: '委内瑞拉' }, { code: 'VG', name: '英属维尔京群岛' }, { code: 'VI', name: '美属维尔京群岛' }, { code: 'VN', name: '越南' }, { code: 'VU', name: '瓦努阿图' }, { code: 'WF', name: '瓦利斯和富图纳' }, { code: 'WS', name: '萨摩亚' }, { code: 'YE', name: '也门' }, { code: 'YT', name: '马约特' }, { code: 'ZA', name: '南非' }, { code: 'ZM', name: '赞比亚' }, { code: 'ZW', name: '津巴布韦' } ] as const export function CountrySection({ control, fieldName }: CountrySectionProps) { const [searchTerm, setSearchTerm] = useState('') const [showSearchResults, setShowSearchResults] = useState(false) return ( { const selectedCodes = (field.value as string[]) || [] const excludeFieldName = fieldName.replace('country', 'country_exclude') const handleAddCountry = (code: string) => { if (!selectedCodes.includes(code)) { field.onChange([...selectedCodes, code]) } setSearchTerm('') setShowSearchResults(false) } const handleRemoveCountry = (code: string) => { field.onChange(selectedCodes.filter(c => c !== code)) } const getCountryName = (code: string) => { const country = ALL_COUNTRIES.find(c => c.code === code) return country ? country.name : code } const filteredCountries = ALL_COUNTRIES .filter(country => !selectedCodes.includes(country.code) && (country.name.toLowerCase().includes(searchTerm.toLowerCase()) || country.code.includes(searchTerm)) ) .slice(0, 10) return (
( )} />
{selectedCodes.length > 0 && (
已选择:
{selectedCodes.map(code => ( handleRemoveCountry(code)} > {getCountryName(code)} ({code}) × ))}
)}
常用国家:
{POPULAR_COUNTRIES.map(country => { const isSelected = selectedCodes.includes(country.code) return ( !isSelected && handleAddCountry(country.code)} > {country.name} {isSelected ? '' : '+'} ) })}
搜索其他国家:
{ setSearchTerm(e.target.value) setShowSearchResults(e.target.value.length > 0) }} onFocus={() => setShowSearchResults(searchTerm.length > 0)} onBlur={() => setTimeout(() => setShowSearchResults(false), 200)} /> {showSearchResults && filteredCountries.length > 0 && (
{filteredCountries.map(country => (
handleAddCountry(country.code)} > {country.name} ({country.code})
))}
)}
) }} /> ) } ================================================ FILE: web/src/components/features/share/components/form-sections/filter-section.tsx ================================================ import { Controller, Control } from 'react-hook-form' import { Input } from '@/src/components/ui/input' import { Label } from '@/src/components/ui/label' import { safeParseInt, safeParseFloat } from '../../utils' export function FilterSection({ control }: { control: Control | any> }) { return (
( field.onChange(safeParseFloat(e.target.value))} /> )} />
( field.onChange(safeParseFloat(e.target.value))} /> )} />
( field.onChange(safeParseInt(e.target.value))} /> )} />
( field.onChange(safeParseInt(e.target.value))} /> )} />
) } ================================================ FILE: web/src/components/features/share/components/form-sections/index.ts ================================================ export { BasicInfoSection } from './basic-info-section' export { ConfigSection } from './config-section' export { FilterSection } from './filter-section' export { AliveStatusSection } from './alive-status-section' export { CountrySection } from './country-section' ================================================ FILE: web/src/components/features/share/components/share-copy.tsx ================================================ 'use client' import { useState } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/src/components/ui/dialog' import { Input } from '@/src/components/ui/input' import { Button } from '@/src/components/ui/button' import { Copy, Check } from 'lucide-react' import { copyToClipboard } from '../utils' import { UI_TEXT } from '../constants' interface ShareCopyDialogProps { fullUrl: string isOpen: boolean onClose: () => void } export function ShareCopyDialog({ fullUrl, isOpen, onClose }: ShareCopyDialogProps) { const [copied, setCopied] = useState(false) const handleCopy = async () => { const success = await copyToClipboard(fullUrl) if (success) { setCopied(true) setTimeout(() => setCopied(false), 2000) // 2秒后重置状态 } } const handleClose = () => { setCopied(false) onClose() } return ( 订阅链接

请复制以下订阅链接:

e.currentTarget.select()} />
{copied && (

链接已复制到剪贴板!

)}
) } ================================================ FILE: web/src/components/features/share/components/share-date-pick.tsx ================================================ "use client" import * as React from "react" import { ChevronDownIcon } from "lucide-react" import { Button } from "@/src/components/ui/button" import { Calendar } from "@/src/components/ui/calendar" import { Label } from "@/src/components/ui/label" import { Popover, PopoverContent, PopoverTrigger, } from "@/src/components/ui/popover" interface Calendar22Props { value?: number onChange?: (timestamp: number) => void } export function Calendar22({ value = 0, onChange, }: Calendar22Props) { const [open, setOpen] = React.useState(false) // 将时间戳转换为日期对象 const selectedDate = React.useMemo(() => { if (!value || value === 0) return undefined return new Date(value * 1000) }, [value]) // 处理日期选择 const handleDateSelect = React.useCallback((date: Date | undefined) => { if (date && onChange) { const timestamp = Math.floor(date.getTime() / 1000) onChange(timestamp) } else if (!date && onChange) { onChange(0) } setOpen(false) }, [onChange]) // 处理"永不过期"按钮点击 const handleNeverExpires = React.useCallback(() => { if (onChange) { onChange(0) } setOpen(false) }, [onChange]) // 显示文本 const displayText = React.useMemo(() => { if (!value || value === 0) { return "永不过期" } if (selectedDate) { return selectedDate.toLocaleDateString('zh-CN') } return "选择日期" }, [selectedDate, value]) // 禁用过去的日期 const isDateDisabled = React.useCallback((date: Date) => { const today = new Date() today.setHours(0, 0, 0, 0) return date < today }, []) return (
) } ================================================ FILE: web/src/components/features/share/components/share-form.tsx ================================================ import { Button } from "@/src/components/ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/src/components/ui/dialog" import { useShareForm } from "../hooks" import { UI_TEXT } from "../constants" import { BasicInfoSection, ConfigSection, FilterSection, AliveStatusSection, CountrySection } from "./form-sections" import { SubscriptionSection } from "@/src/components/shared/subscription-section" import type { ShareRequest } from "@/src/types" interface ShareFormProps { initialData?: ShareRequest formTitle: string isOpen: boolean onClose: () => void editingShareId?: number | undefined } export function ShareForm({ initialData, formTitle, isOpen, onClose, editingShareId, }: ShareFormProps) { const { form, onSubmit, isEditing, isLoading } = useShareForm({ initialData, editingShareId, onSuccess: onClose, isOpen, }) const { control } = form return ( {formTitle}
{/* 基础信息 */} {/* 配置设置 */} {/* 订阅选择 */} {/* 过滤条件 */} {/* 存活状态选择 */} {/* 操作按钮 */}
) } ================================================ FILE: web/src/components/features/share/components/share-list.tsx ================================================ import { useMemo, useEffect } from 'react' import { Card, CardContent } from "@/src/components/ui/card" import { Table, TableBody, TableCell, TableRow } from "@/src/components/ui/table" import { InlineLoading } from "@/src/components/ui/loading" import StatusBadge from "@/src/components/shared/status-badge" import { Button } from "@/src/components/ui/button" import { Edit, Trash2, Copy } from "lucide-react" import { useShares } from "@/src/lib/queries/share-queries" import { useShareOperations } from "../hooks" import { formatAccessCount, formatExpiresTime } from "../utils" import { UI_TEXT } from "../constants" import { useOverflowDetection } from "@/src/lib/hooks/useOverflowDetection" import type { ShareResponse } from "@/src/types/share" interface ShareListProps { onEdit: (share: ShareResponse) => void openCopyDialog: (fullUrl: string) => void } export function ShareList({ onEdit, openCopyDialog }: ShareListProps) { const { data: shares = [], isLoading, error } = useShares() const { handleDelete, handleCopy } = useShareOperations() const { containerRef, contentRef, isOverflowing, checkOverflow } = useOverflowDetection() const sortedShares = useMemo(() => [...shares].sort((a, b) => a.id - b.id), [shares] ) const onCopyClick = (token: string) => { handleCopy(token, openCopyDialog) } const onDeleteClick = (id: number, name: string) => { handleDelete(id, name) } useEffect(() => { if (!isLoading) { checkOverflow() } }, [isLoading, checkOverflow]) if (isLoading) { return ( ) } if (error) { return (
加载失败: {error.message}
) } if (shares.length === 0) { return (
{UI_TEXT.NO_DATA},点击上方按钮创建第一个分享
) } return (
{sortedShares.map((share) => (
{share.name}
访问: {formatAccessCount(share.access_count, share.max_access_count)}
过期日期: {formatExpiresTime(share.expires)}
))}
) } ================================================ FILE: web/src/components/features/share/components/share-page.tsx ================================================ import { useState, useCallback } from "react" import { Button } from "@/src/components/ui/button" import { Plus } from "lucide-react" import { ShareForm } from "./share-form" import { ShareList } from "./share-list" import { ShareCopyDialog } from "./share-copy" import { UI_TEXT } from "../constants" import type { ShareResponse, ShareRequest } from "@/src/types/share" export function SharePage() { // 表单状态 const [isDialogOpen, setIsDialogOpen] = useState(false) const [editingShare, setEditingShare] = useState(null) const [formData, setFormData] = useState(undefined) // 复制对话框状态 const [isCopyDialogOpen, setIsCopyDialogOpen] = useState(false) const [copyUrl, setCopyUrl] = useState('') // 打开编辑对话框 const openEditDialog = useCallback((share: ShareResponse) => { setEditingShare(share) // 从 ShareResponse 转换为 ShareRequest,移除只读字段 const { id: _id, access_count: _access_count, ...shareRequest } = share setFormData(shareRequest) setIsDialogOpen(true) }, []) // 打开创建对话框 const openCreateDialog = useCallback(() => { setEditingShare(null) setFormData(undefined) setIsDialogOpen(true) }, []) // 关闭表单对话框 const closeFormDialog = useCallback(() => { setIsDialogOpen(false) // 延迟清理状态,等待对话框关闭动画完成 setTimeout(() => { setEditingShare(null) setFormData(undefined) }, 200) // 对话框关闭动画通常是 150-200ms }, []) // 打开复制对话框 const openCopyDialog = useCallback((fullUrl: string) => { setCopyUrl(fullUrl) setIsCopyDialogOpen(true) }, []) // 关闭复制对话框 const closeCopyDialog = useCallback(() => { setIsCopyDialogOpen(false) setCopyUrl('') }, []) // 获取表单标题 const formTitle = editingShare ? UI_TEXT.EDIT_SHARE : UI_TEXT.CREATE_SHARE return (
{/* 页面头部 */}

分享管理

{/* 分享表单对话框 */} {/* 分享列表 */}
{/* 复制链接对话框 */}
) } ================================================ FILE: web/src/components/features/share/constants/index.ts ================================================ export const SHARE_CONSTANTS = { TOKEN_LENGTH: 32, DEFAULT_EXPIRES_HOURS: 0, DEFAULT_RULE_URL: 'https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_Full_NoAuto.ini', DEFAULT_RENAME_TEMPLATE: '{{.Country.Emoji}}{{.Country.NameZh}} {{.Delay}} {{.Count}}', } as const export const SUBSCRIPTION_TARGETS = [ { value: 'qx', label: 'QX' }, { value: 'QuantumultX', label: 'Quantumult X' }, { value: 'surge', label: 'Surge' }, { value: 'SurgeMac', label: 'SurgeMac' }, { value: 'Loon', label: 'Loon' }, { value: 'mihomo', label: 'Mihomo' }, { value: 'uri', label: 'URI' }, { value: 'v2', label: 'V2Ray' }, { value: 'json', label: 'JSON' }, { value: 'stash', label: 'Stash' }, { value: 'shadowrocket', label: 'Shadowrocket' }, { value: 'surfboard', label: 'Surfboard' }, { value: 'singbox', label: 'Sing-Box' }, { value: 'egern', label: 'Egern' }, ] as const export const FORM_VALIDATION = { NAME_REQUIRED: '请输入分享名称', POSITIVE_NUMBER: '请输入正数', VALID_COUNTRY_CODE: '请输入有效的国家代码', } as const export const UI_TEXT = { CREATE_SHARE: '创建分享', EDIT_SHARE: '编辑分享', UPDATE: '更新', CREATE: '创建', CANCEL: '取消', DELETE: '删除', COPY: '复制', LOADING: '加载中...', NO_DATA: '暂无数据', CONFIRM_DELETE: '确认删除', DELETE_CONFIRM_MESSAGE: '您确定要删除分享 "{name}" 吗?此操作无法撤销。', COPY_SUCCESS: '复制成功', COPY_FAILED: '复制失败', CREATE_SUCCESS: '分享创建成功', UPDATE_SUCCESS: '分享更新成功', DELETE_SUCCESS: '分享删除成功', CREATE_FAILED: '创建分享失败', UPDATE_FAILED: '更新分享失败', DELETE_FAILED: '删除分享失败', } as const ================================================ FILE: web/src/components/features/share/constants/sub-rules.ts ================================================ import type { KeyValue } from "@/src/types" export const SUB_RULES: KeyValue[] = [ { "key": "默认", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_Full_NoAuto.ini" }, { "key": "默认(自动测速)", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_Full_AdblockPlus.ini" }, { "key": "默认(索尼电视专用)", "value": "https://raw.githubusercontent.com/youshandefeiyang/webcdn/main/SONY.ini" }, { "key": "默认(附带用于 Clash 的 AdGuard DNS)", "value": "https://gist.githubusercontent.com/tindy2013/1fa08640a9088ac8652dbd40c5d2715b/raw/default_with_clash_adg.yml" }, { "key": "ACL_全分组 Dream修改版", "value": "https://raw.githubusercontent.com/WC-Dream/ACL4SSR/WD/Clash/config/ACL4SSR_Online_Full_Dream.ini" }, { "key": "ACL_精简分组 Dream修改版", "value": "https://raw.githubusercontent.com/WC-Dream/ACL4SSR/WD/Clash/config/ACL4SSR_Mini_Dream.ini" }, { "key": "emby-TikTok-流媒体分组-去广告加强版", "value": "https://raw.githubusercontent.com/justdoiting/ClashRule/main/GeneralClashRule.ini" }, { "key": "流媒体通用分组", "value": "https://raw.githubusercontent.com/cutethotw/ClashRule/main/GeneralClashRule.ini" }, { "key": "ACL_默认版", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online.ini" }, { "key": "ACL_无测速版", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_NoAuto.ini" }, { "key": "ACL_去广告版", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_AdblockPlus.ini" }, { "key": "ACL_多国家版", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_MultiCountry.ini" }, { "key": "ACL_无Reject版", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_NoReject.ini" }, { "key": "ACL_无测速精简版", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_Mini_NoAuto.ini" }, { "key": "ACL_全分组版", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_Full.ini" }, { "key": "ACL_全分组谷歌版", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_Full_Google.ini" }, { "key": "ACL_全分组多模式版", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_Full_MultiMode.ini" }, { "key": "ACL_全分组奈飞版", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_Full_Netflix.ini" }, { "key": "ACL_精简版", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_Mini.ini" }, { "key": "ACL_去广告精简版", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_Mini_AdblockPlus.ini" }, { "key": "ACL_Fallback精简版", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_Mini_Fallback.ini" }, { "key": "ACL_多国家精简版", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_Mini_MultiCountry.ini" }, { "key": "ACL_多模式精简版", "value": "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_Mini_MultiMode.ini" }, { "key": "常规规则", "value": "https://raw.githubusercontent.com/flyhigherpi/merlinclash_clash_related/master/Rule_config/ZHANG.ini" }, { "key": "OoHHHHHHH", "value": "https://raw.githubusercontent.com/OoHHHHHHH/ini/master/config.ini" }, { "key": "CFW-TAP", "value": "https://raw.githubusercontent.com/OoHHHHHHH/ini/master/cfw-tap.ini" }, { "key": "lhl77全分组(定期更新)", "value": "https://raw.githubusercontent.com/lhl77/sub-ini/main/tsutsu-full.ini" }, { "key": "lhl77简易版(定期更新)", "value": "https://raw.githubusercontent.com/lhl77/sub-ini/main/tsutsu-mini-gfw.ini" }, { "key": "ConnersHua 神机规则 Outbound", "value": "https://gist.githubusercontent.com/tindy2013/1fa08640a9088ac8652dbd40c5d2715b/raw/connershua_new.ini" }, { "key": "ConnersHua 神机规则 Inbound 回国专用", "value": "https://gist.githubusercontent.com/tindy2013/1fa08640a9088ac8652dbd40c5d2715b/raw/connershua_backtocn.ini" }, { "key": "lhie1 洞主规则(使用 Clash 分组规则)", "value": "https://gist.githubusercontent.com/tindy2013/1fa08640a9088ac8652dbd40c5d2715b/raw/lhie1_clash.ini" }, { "key": "lhie1 洞主规则完整版", "value": "https://gist.githubusercontent.com/tindy2013/1fa08640a9088ac8652dbd40c5d2715b/raw/lhie1_dler.ini" }, { "key": "eHpo1 规则", "value": "https://gist.githubusercontent.com/tindy2013/1fa08640a9088ac8652dbd40c5d2715b/raw/ehpo1_main.ini" }, { "key": "品云专属配置(仅香港区域分组)", "value": "https://raw.githubusercontent.com/Mazeorz/airports/master/Clash/Examine.ini" }, { "key": "品云专属配置(全地域分组)", "value": "https://raw.githubusercontent.com/Mazeorz/airports/master/Clash/Examine_Full.ini" }, { "key": "nzw9314 规则", "value": "https://gist.githubusercontent.com/tindy2013/1fa08640a9088ac8652dbd40c5d2715b/raw/nzw9314_custom.ini" }, { "key": "maicoo-l 规则", "value": "https://gist.githubusercontent.com/tindy2013/1fa08640a9088ac8652dbd40c5d2715b/raw/maicoo-l_custom.ini" }, { "key": "DlerCloud Platinum 李哥定制规则", "value": "https://gist.githubusercontent.com/tindy2013/1fa08640a9088ac8652dbd40c5d2715b/raw/dlercloud_lige_platinum.ini" }, { "key": "DlerCloud Gold 李哥定制规则", "value": "https://gist.githubusercontent.com/tindy2013/1fa08640a9088ac8652dbd40c5d2715b/raw/dlercloud_lige_gold.ini" }, { "key": "DlerCloud Silver 李哥定制规则", "value": "https://gist.githubusercontent.com/tindy2013/1fa08640a9088ac8652dbd40c5d2715b/raw/dlercloud_lige_silver.ini" }, { "key": "ShellClash修改版规则 (by UlinoyaPed)", "value": "https://github.com/UlinoyaPed/ShellClash/raw/master/rules/ShellClash.ini" }, { "key": "CNIX", "value": "https://raw.githubusercontent.com/Mazeorz/airports/master/Clash/SSRcloud.ini" }, { "key": "Nirvana", "value": "https://raw.githubusercontent.com/Mazetsz/ACL4SSR/master/Clash/config/V2rayPro.ini" }, { "key": "V2Pro", "value": "https://raw.githubusercontent.com/Mazeorz/airports/master/Clash/V2Pro.ini" }, { "key": "史迪仔-自动测速", "value": "https://raw.githubusercontent.com/Mazeorz/airports/master/Clash/Stitch.ini" }, { "key": "史迪仔-负载均衡", "value": "https://raw.githubusercontent.com/Mazeorz/airports/master/Clash/Stitch-Balance.ini" }, { "key": "Maying", "value": "https://raw.githubusercontent.com/SleepyHeeead/subconverter-config/master/remote-config/customized/maying.ini" }, { "key": "Basic", "value": "https://raw.githubusercontent.com/SleepyHeeead/subconverter-config/master/remote-config/special/basic.ini" } ] as const ================================================ FILE: web/src/components/features/share/hooks/index.ts ================================================ export { useShareForm } from './useShareForm' export { useShareOperations } from './useShareOperations' ================================================ FILE: web/src/components/features/share/hooks/useShareForm.ts ================================================ import { useForm } from 'react-hook-form' import { useEffect, useMemo } from 'react' import { toast } from 'sonner' import { useCreateShare, useUpdateShare } from '@/src/lib/queries/share-queries' import { generateToken, createDefaultShareData } from '../utils' import { UI_TEXT } from '../constants' import type { ShareRequest } from '@/src/types' interface UseShareFormProps { initialData?: ShareRequest | undefined editingShareId?: number | undefined onSuccess?: () => void isOpen?: boolean } export function useShareForm({ initialData, editingShareId, onSuccess, isOpen = true }: UseShareFormProps = {}) { const createShareMutation = useCreateShare() const updateShareMutation = useUpdateShare() const defaultData = useMemo(() => createDefaultShareData(), []) const form = useForm({ defaultValues: initialData || defaultData }) const { handleSubmit, reset, watch } = form useEffect(() => { if (isOpen) { reset(initialData || defaultData) } }, [initialData, reset, defaultData, isOpen]) const onSubmit = async (data: ShareRequest) => { try { const token = data.token || generateToken() const submitData: ShareRequest = { ...data, token, } if (editingShareId) { await updateShareMutation.mutateAsync({ id: editingShareId, data: submitData }) toast.success(UI_TEXT.UPDATE_SUCCESS) } else { await createShareMutation.mutateAsync(submitData) toast.success(UI_TEXT.CREATE_SUCCESS) } onSuccess?.() } catch (error) { const errorMessage = editingShareId ? UI_TEXT.UPDATE_FAILED : UI_TEXT.CREATE_FAILED toast.error(errorMessage) console.error('Failed to save share:', error) } } const isLoading = editingShareId ? updateShareMutation.isPending : createShareMutation.isPending return { form, onSubmit: handleSubmit(onSubmit), watch, isEditing: !!editingShareId, isLoading, } } ================================================ FILE: web/src/components/features/share/hooks/useShareOperations.ts ================================================ import { toast } from 'sonner' import { useAlert } from '@/src/components/providers' import { useDeleteShare } from '@/src/lib/queries/share-queries' import { copyToClipboard, buildShareUrl } from '../utils' import { UI_TEXT } from '../constants' export function useShareOperations() { const { confirm } = useAlert() const deleteShareMutation = useDeleteShare() const handleDelete = async (id: number, name: string) => { const confirmed = await confirm({ title: UI_TEXT.CONFIRM_DELETE, description: UI_TEXT.DELETE_CONFIRM_MESSAGE.replace('{name}', name), confirmText: UI_TEXT.DELETE, cancelText: UI_TEXT.CANCEL, variant: 'destructive' }) if (confirmed) { try { await deleteShareMutation.mutateAsync(id) toast.success(UI_TEXT.DELETE_SUCCESS) } catch (error) { toast.error(UI_TEXT.DELETE_FAILED) console.error('Failed to delete share:', error) } } } const handleCopy = async (token: string, onFallback?: (url: string) => void) => { const fullUrl = buildShareUrl(token) const success = await copyToClipboard(fullUrl) if (success) { toast.success(UI_TEXT.COPY_SUCCESS) } else { if (onFallback) { onFallback(fullUrl) } else { toast.error(UI_TEXT.COPY_FAILED) } } } return { handleDelete, handleCopy, isDeleting: deleteShareMutation.isPending, } } ================================================ FILE: web/src/components/features/share/index.ts ================================================ export { SharePage } from './components/share-page' ================================================ FILE: web/src/components/features/share/utils/index.ts ================================================ import { SHARE_CONSTANTS } from '../constants' import type { ShareRequest } from '@/src/types' /** * 生成随机 token */ export function generateToken(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' let token = '' for (let i = 0; i < SHARE_CONSTANTS.TOKEN_LENGTH; i++) { token += chars.charAt(Math.floor(Math.random() * chars.length)) } return token } /** * 构建分享链接 */ export function buildShareUrl(token: string, baseUrl?: string): string { const origin = baseUrl || (typeof window !== 'undefined' ? window.location.origin : '') return `${origin}/api/v1/share/sub/${token}` } /** * 复制文本到剪贴板 */ export async function copyToClipboard(text: string): Promise { // 优先使用现代 Clipboard API if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text) return true } catch (error) { console.warn('Clipboard API failed:', error) return fallbackCopyToClipboard(text) } } // 降级到传统方法 return fallbackCopyToClipboard(text) } /** * 降级复制方法 */ function fallbackCopyToClipboard(text: string): boolean { const textArea = document.createElement('textarea') textArea.value = text textArea.style.cssText = ` position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; ` document.body.appendChild(textArea) textArea.focus() textArea.select() try { const successful = document.execCommand('copy') return successful } catch (error) { console.warn('Fallback copy failed:', error) return false } finally { document.body.removeChild(textArea) } } /** * 创建默认的分享表单数据 */ export function createDefaultShareData(): ShareRequest { return { name: '', enable: true, token: '', gen: { filter: { sub_id_exclude: false, country_exclude: false, sub_id: [], speed_up_more: 0, speed_down_more: 0, country: [], delay_less_than: 0, alive_status: 0, risk_less_than: 0, }, rename: SHARE_CONSTANTS.DEFAULT_RENAME_TEMPLATE, proxy: false, target: 'auto', }, max_access_count: 0, expires: SHARE_CONSTANTS.DEFAULT_EXPIRES_HOURS, } } /** * 验证国家代码格式 */ export function validateCountryCodes(codes: string): string[] { return codes .split(',') .map(code => code.trim()) .filter(Boolean) .filter(code => /^\d+$/.test(code)) // 只允许数字 } /** * 格式化访问次数显示 */ export function formatAccessCount(current: number, max: number): string { return `${current}/${max === 0 ? '∞' : max}` } /** * 格式化过期时间显示 */ export function formatExpiresTime(expires: number): string { if (expires === 0) { return '永不过期' } const date = new Date(expires * 1000) return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', }) } /** * 检查是否为自定义规则配置 */ export function isCustomConfig(config: string, availableRules: Array<{ value: string }>): boolean { return !availableRules.some(rule => rule.value === config) } /** * 安全的数字转换 */ export function safeParseInt(value: string, defaultValue = 0): number { const parsed = parseInt(value, 10) return isNaN(parsed) ? defaultValue : parsed } /** * 安全的浮点数转换 */ export function safeParseFloat(value: string, defaultValue = 0): number { const parsed = parseFloat(value) return isNaN(parsed) ? defaultValue : parsed } ================================================ FILE: web/src/components/features/storage/storage.tsx ================================================ "use client" import { Card, CardContent, CardHeader, CardTitle } from "@/src/components/ui/card" export function StoragePage() { return (

存储配置

配置您的存储设置

存储设置
存储配置功能正在开发中...
) } ================================================ FILE: web/src/components/features/sub/components/batch-sub-form.tsx ================================================ import { useState } from 'react' import { useForm, Control } from 'react-hook-form' import { Button } from '@/src/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/src/components/ui/dialog' import { Textarea } from '@/src/components/ui/textarea' import { Label } from '@/src/components/ui/label' import { toast } from 'sonner' import { ConfigSection } from './form-sections' import { useBatchCreateSub } from '@/src/lib/queries/sub-queries' import { generateNameFromUrl } from '../utils' import type { SubRequest } from '@/src/types/sub' interface BatchSubFormProps { isOpen: boolean onClose: () => void } interface BatchFormData extends SubRequest { urls: string } export function BatchSubForm({ isOpen, onClose }: BatchSubFormProps) { const [isSubmitting, setIsSubmitting] = useState(false) const batchCreateMutation = useBatchCreateSub() const form = useForm({ defaultValues: { urls: '', name: '', tags: [], enable: true, cron_expr: '0 */1 * * *', config: { url: '', proxy: false, timeout: 10, }, } }) const { control, handleSubmit, reset } = form const onSubmit = async (data: BatchFormData) => { setIsSubmitting(true) try { const urls = data.urls .split('\n') .map(url => url.trim()) .filter(url => url && /^https?:\/\/.+/.test(url)) if (urls.length === 0) { toast.error('请输入至少一个有效的订阅链接') return } const subscriptions: SubRequest[] = urls.map(url => ({ name: generateNameFromUrl(url) || '未知订阅', tags: data.tags || [], enable: data.enable, cron_expr: data.cron_expr, config: { url, proxy: data.config.proxy || false, timeout: data.config.timeout || 10, }, })) const results = await batchCreateMutation.mutateAsync(subscriptions) const successCount = results.length if (successCount > 0) { toast.success(`成功添加 ${successCount} 个订阅`) reset() onClose() } } catch (error) { toast.error('批量添加失败') console.error('Failed to batch create subscriptions:', error) } finally { setIsSubmitting(false) } } const handleClose = () => { reset() onClose() } return ( 批量添加订阅