commit 8a782525deaae4b437250e48f6e52c38438f3549 Author: ryan Date: Mon Jun 8 20:34:27 2026 +0800 压缩历史至 95081aff diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..1975d64b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +charset = utf-8 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{json,yml,yaml}] +indent_size = 2 + +[*.md] +insert_final_newline = false +trim_trailing_whitespace = false + +[*.{js,ts,css,html,jsx,tsx,vue}] +indent_size = 2 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..9c4ebc20 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,90 @@ +name: 使用时的错误报告 +description: 某些事情不按照预期工作。 +title: "bug: " +labels: ["bug"] + +body: + - type: markdown + attributes: + value: | + 感谢您花时间填写此 Bug 报告! + + - **提交错误报告前**:请检查 [已有 Issues](https://github.com/linux-do/credit/issues)列表,了解是否有类似问题被报告。如果不确定,请进行搜索,这有助于我们高效地专注于改进项目。 + - type: checkboxes + id: issue-check + attributes: + label: 检查现有问题 + description: 确认您在提交新报告之前已经检查了现有报告。 + options: + - label: 我已经搜索了现有问题和讨论。 + required: true + - label: 我正在使用最新版本或 credit.linux.do 上的此应用。 + required: true + - type: textarea + id: what-happened + attributes: + label: 发生了什么? + description: 请详细描述您正在进行的操作,您期待看到什么,实际发生了什么。 + placeholder: 请告诉我们您看到了什么! + validations: + required: true + - type: textarea + id: steps-to-reproduce + attributes: + label: 如何重现此 Bug? + description: 请提供详细的步骤来重现此 Bug。 + placeholder: | + 1. 在此环境中... + 2. 使用此配置... + 3. 运行 '...' + 4. 看到错误... + validations: + required: true + - type: dropdown + id: browsers + attributes: + label: 在哪些浏览器中出现问题? + multiple: true + options: + - Firefox + - Chrome + - Safari + - Microsoft Edge + - Other (请在“其他信息”中说明) + validations: + required: false + - type: textarea + id: other-info + attributes: + label: 任何其他信息 + description: 您有任何其他关于此报告的信息吗? + validations: + required: false + - type: checkboxes + id: confirmation + attributes: + label: 确认 + description: 确保已满足以下先决条件。 + options: + - label: 我已阅读并遵循了 `README.md` 中的所有说明。 + required: true + - label: 我正在使用 linux-do/credit 的最新版本。 + required: true + - label: 我已提供我能够提供的尽可能多的相关日志,屏幕截图等。 + required: true + - label: | + 我已详细记录了精确、按顺序且无歧义的逐步重现说明。我的步骤: + - 从正在执行的操作开始, + - 指定进入了什么页面, + - 列出访问的 URL、用户输入(包括所需的示例值/电子邮件/密码), + - 描述所有已启用或更改的选项和开关, + - 包含任何可能的浏览器控制台日志, + - 识别每个阶段的预期和实际结果, + - 确保任何有合理技能的用户都可以遵循并遇到相同的问题。 + required: true + - type: markdown + attributes: + value: | + ## 注意 + 如果 Bug 报告不完整或不遵循说明,则可能不会得到处理。请确保您已遵循所有 **README.md** 指南,并提供所有必要信息以便我们重现该问题。 + 感谢您为 linux-do/credit 做出贡献! diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..ec4bb386 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..7faa5ea3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,79 @@ +name: 新功能建议 +description: 请求新的功能或对现有功能进行改进。 +title: "feature: " +labels: ["enhancement"] + +body: + - type: markdown + attributes: + value: | + 感谢您花时间填写此功能请求! + + - **提交功能请求前**:请检查 [已有 Issues](https://github.com/linux-do/credit/issues) 列表和讨论区,了解是否有类似功能已被讨论或请求。这有助于我们避免重复工作,并高效地专注于改进项目。 + - type: checkboxes + id: check-existing + attributes: + label: 检查现有问题和讨论 + description: 确认您在提交新请求之前已经检查了现有报告和讨论。 + options: + - label: 我已经搜索了现有问题和讨论。 + required: true + - label: 我正在使用最新版本或 credit.linux.do 上的此应用。 + required: true + - type: textarea + id: feature-description + attributes: + label: 你希望添加什么功能或改进什么? + description: 请详细描述您希望添加的功能或进行的改进。 + placeholder: 我希望可以... + validations: + required: true + - type: textarea + id: why-needed + attributes: + label: 为什么需要此功能? + description: 请说明此功能解决了什么问题,或提供了什么价值。请提供具体的用例和场景,帮助我们理解其重要性。 + placeholder: | + 目前我遇到... + 如果有了此功能,我可以... + 这将为用户带来... + validations: + required: true + - type: textarea + id: proposed-solution + attributes: + label: 建议的解决方案(可选) + description: 如果您对如何实现此功能有任何想法,请在此处描述。这可以包括用户界面草图、API 设想、技术方案等。 + placeholder: | + 我设想此功能可以通过以下方式实现: + 1. ... + 2. ... + validations: + required: false + - type: textarea + id: other-info + attributes: + label: 任何其他信息 + description: 您有任何其他关于此报告的信息吗?例如,您目前如何解决这个问题,或者其他类似项目的实现方式等。 + validations: + required: false + - type: checkboxes + id: confirmation + attributes: + label: 确认 + description: 确保已满足以下先决条件。 + options: + - label: 我已阅读并遵循了 `README.md` 中的所有说明。 + required: true + - label: 我正在使用 linux-do/credit 的最新版本。 + required: true + - label: 我已提供我能够提供的尽可能多的相关信息,包括用例和场景。 + required: true + - label: 我理解功能请求的实现取决于项目优先级和资源。 + required: true + - type: markdown + attributes: + value: | + ## 注意 + 如果功能请求不完整或不遵循说明,则可能不会得到处理。请确保您已提供所有必要信息以便我们理解您的建议。 + 感谢您为 linux-do/credit 做出贡献! diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..c9aad0d7 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,30 @@ +## 基础规范 + +- 在任何情况都使用简体中文 +- 你是一个专业的代码助手,专门为 LINUX DO Credit 项目提供代码编写和优化服务 +- 严格遵循项目的代码规范和最佳实践,确保代码质量和一致性 +- 保持代码简洁、可读、高效 +- 优先考虑项目的可维护性、性能和安全性,避免引入不必要的复杂性 +- 注释和上一行代码之间保留一行空格 +- 编写代码前仔细分析需求,确保改动有实际价值和意义 +- 避免仅修改格式、注释或无影响力的拼写错误 +- 重构代码时必须带来可维护性或功能上的实质提升 +- 新增功能时考虑向后兼容性和 API 稳定性 +- 遵循项目的 MIT 许可证要求 +- 遵循语义化版本控制规范 + +## 后端规范 + +- 后端开发使用 Go 语言,所有接口需要符合 Restful 风格 +- 数据库使用 PostgreSQL 作为主存储,Redis 作为缓存和会话存储 +- Go 代码遵循 gofmt 标准格式,使用 snake_case 命名数据库字段 +- 所有 API 接口必须编写完整的 Swagger 文档 +- API 响应格式统一为 {"error_msg": "", "data": {}} 结构 +- 分页数据返回 {"error_msg": "", "data": {"total": 0, "results": []}} 格式 +- 数据库设计禁止使用外键,但必须保留对应字段的索引 + +## 前端规范 + +- TypeScript 代码严禁使用 any 类型,优先使用 unknown 进行类型安全处理 +- 组件按功能分类:公共组件放在 components/common,UI 组件放在 components/ui +- 自定义图标统一放置在 components/icons 目录,常规图标使用 Lucide 库 diff --git a/.github/git-commit-instructions.md b/.github/git-commit-instructions.md new file mode 100644 index 00000000..d8cf4949 --- /dev/null +++ b/.github/git-commit-instructions.md @@ -0,0 +1,3 @@ +- 如果有其他代码文件,忽略 Swagger 变更和版本号变更,只需要关注其他代码文件的变更 +- 需要符合 Github 的提交规范,使用 (): 格式 +- Commit Message 必须有 Scope 信息 \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..efad130f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +**例行检查** + + + +- [ ] 我已阅读并理解 [贡献者公约](https://github.com/linux-do/credit/blob/master/CODE_OF_CONDUCT.md) +- [ ] 我已阅读并同意 [贡献者许可协议 (CLA)](https://github.com/linux-do/credit/blob/master/CLA.md),确认我的贡献将根据项目的 Apache2.0 许可证进行许可 +- [ ] 我知晓如果此 PR 并不做出实质性更改,或可被认为是*为了PR被合并而提交PR*的,则可能不会被合并 + +**关联信息** + + + + + +**变更内容** + + + +**变更原因** + + \ No newline at end of file diff --git a/.github/workflows/build_backend.yml b/.github/workflows/build_backend.yml new file mode 100644 index 00000000..44885366 --- /dev/null +++ b/.github/workflows/build_backend.yml @@ -0,0 +1,40 @@ +name: Go Build + +on: + pull_request: + branches: [ "*" ] + push: + branches: + - "dev" + - "main" + +jobs: + go_build: + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + check-latest: true + + - name: Install dependencies + run: | + go mod download + go install github.com/swaggo/swag/cmd/swag@v1.16.6 + + - name: Make Swagger + run: make swagger + + - name: Diff Files + run: | + if ! git diff --exit-code; then + echo 'Detected unstaged changes after command. Please make swagger locally.' + exit 1 + fi + + - name: Build + run: go build main.go diff --git a/.github/workflows/build_frontend.yml b/.github/workflows/build_frontend.yml new file mode 100644 index 00000000..22e8d969 --- /dev/null +++ b/.github/workflows/build_frontend.yml @@ -0,0 +1,36 @@ +name: NextJS Build + +on: + pull_request: + branches: [ "*" ] + push: + branches: + - "dev" + - "main" + +jobs: + nextjs_build: + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.10.0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: 'pnpm' + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + + - name: Build + working-directory: frontend + run: pnpm build diff --git a/.github/workflows/build_image.yml b/.github/workflows/build_image.yml new file mode 100644 index 00000000..47c0c964 --- /dev/null +++ b/.github/workflows/build_image.yml @@ -0,0 +1,200 @@ +name: Build Docker Image + +on: + push: + branches: [ "master" ] + tags: [ "*" ] + pull_request: + branches: [ "master" ] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + FRONTEND_IMAGE_NAME: ${{ github.repository }}-web + +# add permission for package publishing +permissions: + contents: read + packages: write + +jobs: + build_image: + runs-on: ubuntu-24.04 + steps: + # checkout code + - name: Checkout repository + uses: actions/checkout@v4 + + # set up go + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + check-latest: true + + # download Go modules + - name: Download Go modules + run: go mod download + + # build do + - name: Build Go Binary (multi-arch) + run: | + # build amd64 binary + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o credit-server-amd64 main.go + + # build arm64 binary + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o credit-server-arm64 main.go + + # show binaries + ls -alh credit-server-* + + # set up docker buildx + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # login to registry + - name: Log into registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # extract metadata for docker + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=tag + type=sha,format=short,prefix= + + # build and push docker image + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.ref_type == 'tag' || (github.ref == 'refs/heads/master' && github.event_name == 'push') }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 + + build_frontend_image: + runs-on: ubuntu-24.04 + steps: + # checkout code + - name: Checkout repository + uses: actions/checkout@v4 + + # set up docker buildx + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # login to registry + - name: Log into registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # extract metadata for docker (frontend) + - name: Extract Docker metadata (frontend) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }} + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=tag + type=sha,format=short,prefix= + + # generate frontend build date in utc + - name: Generate frontend build date + id: frontend_build_meta + run: echo "build_date=$(date -u +'%Y/%m/%d UTC')" >> "$GITHUB_OUTPUT" + + # build and push frontend docker image with version argument + - name: Build Frontend Docker image + uses: docker/build-push-action@v5 + with: + context: ./frontend + push: ${{ github.ref_type == 'tag' || (github.ref == 'refs/heads/master' && github.event_name == 'push') }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64 + build-args: | + VERSION=${{ github.ref_type == 'tag' && github.ref_name || '' }} + BUILD_DATE=${{ steps.frontend_build_meta.outputs.build_date }} + + update_helm_values: + runs-on: ubuntu-24.04 + needs: [build_image, build_frontend_image] + if: github.ref_type == 'tag' + steps: + # configure SSH agent with deploy key + - name: Setup SSH agent + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.DEPLOY_KEY }} + + # add github.com to known_hosts to avoid host verification prompt + - name: Add GitHub to known_hosts + run: ssh-keyscan github.com >> ~/.ssh/known_hosts + + # clone the helm charts repository + - name: Clone Helm Charts repository + run: git clone ${{ vars.HELM_CHARTS_REPO }} helm-charts + + # install yq for yaml editing + - name: Install yq + run: | + sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + + # determine helm values file based on tag type (production or test) + - name: Determine Helm values file + id: helm_config + run: | + TAG="${{ github.ref_name }}" + if [[ $TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "values_file=values/credit/credit.yaml" >> "$GITHUB_OUTPUT" + echo "branch_prefix=update/credit" >> "$GITHUB_OUTPUT" + else + echo "values_file=values/credit_test/credit.yaml" >> "$GITHUB_OUTPUT" + echo "branch_prefix=update/credit_test" >> "$GITHUB_OUTPUT" + fi + + # update image tags in helm values file + - name: Update image tags in helm values file + run: | + cd helm-charts + yq -i '.image.tag = "${{ github.ref_name }}"' ${{ steps.helm_config.outputs.values_file }} + yq -i '.image.webTag = "${{ github.ref_name }}"' ${{ steps.helm_config.outputs.values_file }} + + # commit and push to a new branch, then open a PR for review + - name: Commit, push and create PR + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: | + cd helm-charts + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + BRANCH_NAME="${{ steps.helm_config.outputs.branch_prefix }}-${{ github.ref_name }}" + git checkout -b "$BRANCH_NAME" + git add ${{ steps.helm_config.outputs.values_file }} + git commit -m "chore: update credit image tag to ${{ github.ref_name }}" + git push origin "$BRANCH_NAME" + HELM_REPO=$(echo "${{ vars.HELM_CHARTS_REPO }}" | sed 's/git@github.com://;s/\.git$//') + gh pr create \ + --repo "${HELM_REPO}" \ + --title "chore: update credit image tag to ${{ github.ref_name }}" \ + --body "Automated PR: update \`${{ steps.helm_config.outputs.values_file }}\` image tags to \`${{ github.ref_name }}\` after Docker image build." \ + --base main \ + --head "$BRANCH_NAME" diff --git a/.github/workflows/check_license.yml b/.github/workflows/check_license.yml new file mode 100644 index 00000000..b625d9a6 --- /dev/null +++ b/.github/workflows/check_license.yml @@ -0,0 +1,18 @@ +name: check license + +on: + push: + branches: [ "master" ] + tags: [ "*" ] + pull_request: + branches: [ "master" ] + +jobs: + check_license: + runs-on: ubuntu-24.04 + steps: + - name: checkout code + uses: actions/checkout@v4 + + - name: run make add_license + run: make check_license diff --git a/.github/workflows/close_ticket.yml b/.github/workflows/close_ticket.yml new file mode 100644 index 00000000..4ebf1f4a --- /dev/null +++ b/.github/workflows/close_ticket.yml @@ -0,0 +1,24 @@ +name: Close Ticket + +on: + schedule: + - cron: "0 0 * * *" + +jobs: + close_ticket: + runs-on: ubuntu-24.04 + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v9 + with: + days-before-issue-stale: 14 + days-before-issue-close: 14 + stale-issue-message: "此 issue 长期无活动,将在 14 天后自动关闭。如需继续讨论请回复" + close-issue-message: "此 issue 因长期无活动已自动关闭,如有需要请重新开启" + days-before-pr-stale: 14 + days-before-pr-close: 14 + stale-pr-message: "此 PR 长期无活动,将在 14 天后自动关闭。如需继续讨论请回复" + close-pr-message: "此 PR 因长期无活动已自动关闭,如有需要请重新开启" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..7d76f9c8 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,40 @@ +name: "CodeQL" + +on: + pull_request: + branches: [ "*" ] + push: + branches: + - "dev" + - "main" + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-24.04 + permissions: + security-events: write + packages: read + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: go + build-mode: autobuild + - language: javascript-typescript + build-mode: none + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 00000000..9055760c --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,48 @@ +name: "Copilot Setup Steps" + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + copilot-setup-steps: + runs-on: ubuntu-24.04 + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.10.0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install JavaScript dependencies + working-directory: frontend + run: pnpm install + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + check-latest: true + + - name: Install dependencies + run: | + go mod download + go install github.com/swaggo/swag/cmd/swag@v1.16.6 diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml new file mode 100644 index 00000000..f8865db9 --- /dev/null +++ b/.github/workflows/eslint.yml @@ -0,0 +1,35 @@ +name: ESLint + +on: + pull_request: + branches: [ "*" ] + push: + branches: + - "dev" + - "main" + +jobs: + lint: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.10.0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: 'pnpm' + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install dependencies + working-directory: frontend + run: pnpm install + + - name: Run ESLint + working-directory: frontend + run: npx eslint . --max-warnings 0 diff --git a/.github/workflows/pr-template-check.yml b/.github/workflows/pr-template-check.yml new file mode 100644 index 00000000..e61365de --- /dev/null +++ b/.github/workflows/pr-template-check.yml @@ -0,0 +1,32 @@ +name: Check PR Template Checklist + +on: + pull_request: + types: [opened, edited, synchronize] + +jobs: + check-pr-template: + runs-on: ubuntu-24.04 + steps: + - name: check all checklist items are checked + uses: actions/github-script@v7 + with: + script: | + // get the pull request body + const prBody = context.payload.pull_request.body || ''; + + // regex to match all checklist items in the template + // matches lines like: - [ ] ... or - [x] ... + const checklistRegex = /^- \[( |x|X)\] .+$/gm; + const matches = prBody.match(checklistRegex) || []; + + // check if any checklist item is not checked + const unchecked = matches.filter(line => line.startsWith('- [ ]')); + + // if any unchecked, fail the workflow + if (unchecked.length > 0) { + core.setFailed(`PR checklist 未全部勾选,请确保所有 checklist 项都已勾选。未勾选项如下:\n${unchecked.join('\n')}`); + } else { + console.log('all checklist items are checked.'); + } + diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..03dd16dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# IDE +.idea/* +.idea +!.idea/icon.png +.vscode +.pnpm-store/ + +# logs +logs +*.log + +# config +config.yaml + +# frontend +frontend/build +frontend/disk +frontend/node_modules/* +frontend/.next/* +frontend/next-env.d.ts +frontend/package-lock.json +frontend/.env +.env.* +!.env.example +*.tsbuildinfo + +# os +.DS_Store +Thumbs.db + +# build +/build/ +/bin/ +/dist/ + +# go workspace +go.work +go.work.sum +/credit +main + +# upload +uploads/* + +s3_cache diff --git a/.idea/icon.png b/.idea/icon.png new file mode 100644 index 00000000..df85a1c1 Binary files /dev/null and b/.idea/icon.png differ diff --git a/CLA.md b/CLA.md new file mode 100644 index 00000000..eed7d466 --- /dev/null +++ b/CLA.md @@ -0,0 +1,55 @@ +# 贡献者许可协议 (Contributor License Agreement) + +## 中文版 + +### 什么是贡献者许可协议 (CLA)? + +贡献者许可协议 (CLA) 是一份法律文件,明确了您对开源项目贡献代码时的权利和义务。通过签署 CLA,您确认: + +1. **您拥有贡献内容的版权**:您确认您提交的代码、文档或其他内容是您原创的,或者您有权将其贡献给本项目。 + +2. **您同意许可条款**:您同意将您的贡献按照本项目的 MIT 许可证进行许可。 + +3. **您授予项目维护者权利**:您授予项目维护者和 linux.do 社区永久、全球、免版税、不可撤销的权利来使用、修改、分发您的贡献。 + +### 您的贡献将如何使用? + +- 您的贡献将根据 [MIT 许可证](LICENSE) 进行许可 +- 您的贡献可能被修改、整合到项目中或重新分发 +- 您将在项目的贡献者名单中被适当地标识 + +### 如何表示同意? + +在创建 Pull Request 时,请在 PR 描述中勾选 CLA 同意选项,表示您已阅读并同意本贡献者许可协议的条款。 + +--- + +## English Version + +### What is a Contributor License Agreement (CLA)? + +A Contributor License Agreement (CLA) is a legal document that clarifies your rights and obligations when contributing code to an open source project. By agreeing to the CLA, you confirm that: + +1. **You own the copyright to your contributions**: You confirm that the code, documentation, or other content you submit is your original work or you have the right to contribute it to this project. + +2. **You agree to the license terms**: You agree to license your contributions under this project's MIT License. + +3. **You grant rights to project maintainers**: You grant the project maintainers and linux.do community a perpetual, worldwide, royalty-free, irrevocable right to use, modify, and distribute your contributions. + +### How will your contributions be used? + +- Your contributions will be licensed under the [MIT License](LICENSE) +- Your contributions may be modified, integrated into the project, or redistributed +- You will be appropriately credited in the project's contributor list + +### How to indicate agreement? + +When creating a Pull Request, please check the CLA agreement option in the PR description to indicate that you have read and agree to the terms of this Contributor License Agreement. + +--- + +## 许可证信息 (License Information) + +本项目使用 MIT 许可证。详情请查看 [LICENSE](LICENSE) 文件。 + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..852593d4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,98 @@ + +# 贡献者公约 + +## 我们的承诺 + +身为社区成员、贡献者和领袖,我们承诺使社区参与者不受骚扰,无论其年龄、体型、可见或不可见的缺陷、族裔、性征、性别认同和表达、经验水平、教育程度、社会与经济地位、国籍、相貌、种族、种姓、肤色、宗教信仰、性倾向或性取向如何。 + +我们承诺以有助于建立开放、友善、多样化、包容、健康社区的方式行事和互动。 + +## 我们的准则 + +有助于为我们的社区创造积极环境的行为例子包括但不限于: + +* 表现出对他人的同情和善意 +* 尊重不同的主张、观点和感受 +* 提出和大方接受建设性意见 +* 承担责任并向受我们错误影响的人道歉 +* 注重社区共同诉求,而非个人得失 + +不当行为例子包括: + +* 使用情色化的语言或图像,及性引诱或挑逗 +* 嘲弄、侮辱或诋毁性评论,以及人身或政治攻击 +* 公开或私下的骚扰行为 +* 未经他人明确许可,公布他人的私人信息,如物理或电子邮件地址 +* 其他有理由认定为违反职业操守的不当行为 + +## 代码质量与性能准则 + +为确保项目的持续改进和稳定性,我们制定以下代码质量与性能准则: + +* **性能标准**:所有代码变更不得导致项目整体性能下降 +* **性能测试**:提交的 Pull Request 应确保不会引入性能回归 +* **拒绝合并**:如果 Pull Request 导致性能下降,将被拒绝合并,直到性能问题得到解决 +* **优化鼓励**:我们鼓励并欢迎提升系统性能的优化改进 + +## 责任和权力 + +社区领袖有责任解释和落实我们所认可的行为准则,并妥善公正地对他们认为不当、威胁、冒犯或有害的任何行为采取纠正措施。 + +社区领导有权力和责任删除、编辑或拒绝或拒绝与本行为准则不相符的评论(comment)、提交(commits)、代码、维基(wiki)编辑、议题(issues)或其他贡献,并在适当时机知采取措施的理由。 + +## 适用范围 + +本行为准则适用于所有社区场合,也适用于在公共场所代表社区时的个人。 + +代表社区的情形包括使用官方电子邮件地址、通过官方社交媒体帐户发帖或在线上或线下活动中担任指定代表。 + +## 监督 + +辱骂、骚扰或其他不可接受的行为可通过 [插入联系方式] 向负责监督的社区领袖报告。 +所有投诉都将得到及时和公平的审查和调查。 + +所有社区领袖都有义务尊重任何事件报告者的隐私和安全。 + +## 处理方针 + +社区领袖将遵循下列社区处理方针来明确他们所认定违反本行为准则的行为的处理方式: + +### 1. 纠正 + +**社区影响**:使用不恰当的语言或其他在社区中被认定为不符合职业道德或不受欢迎的行为。 + +**处理意见**:由社区领袖发出非公开的书面警告,明确说明违规行为的性质,并解释举止如何不妥。或将要求公开道歉。 + +### 2. 警告 + +**社区影响**:单个或一系列违规行为。 + +**处理意见**:警告并对连续性行为进行处理。在指定时间内,不得与相关人员互动,包括主动与行为准则执行者互动。这包括避免在社区场所和外部渠道中的互动。违反这些条款可能会导致临时或永久封禁。 + +### 3. 临时封禁 + +**社区影响**: 严重违反社区准则,包括持续的不当行为。 + +**处理意见**: 在指定时间内,暂时禁止与社区进行任何形式的互动或公开交流。在此期间,不得与相关人员进行公开或私下互动,包括主动与行为准则执行者互动。违反这些条款可能会导致永久封禁。 + +### 4. 永久封禁 + +**社区影响**:行为模式表现出违反社区准则,包括持续的不当行为、骚扰个人或攻击或贬低某个类别的个体。 + +**处理意见**:永久禁止在社区内进行任何形式的公开互动。 + +## 参见 + +本行为准则改编自 [Contributor Covenant][homepage] 2.1 版, 参见 [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]。 + +社区处理方针灵感来源于 [Mozilla's code of conduct enforcement ladder][Mozilla CoC]。 + +有关本行为准则的常见问题的答案,参见 [https://www.contributor-covenant.org/faq][FAQ]。 +其他语言翻译参见 [https://www.contributor-covenant.org/translations][translations]。 + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..d3cde4e9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,151 @@ +# 贡献指南 + +感谢您有兴趣为本项目做出贡献!我们欢迎各种形式的贡献,但请先阅读如下文档,以节省您和我们的时间。 + +当您在使用 Claude Code, Gemini CLI 等 Vibe-coding 工具时,推荐将此文档内容附加到上下文内。 + +## 我们不接受的更改 + +出于包括但不限于项目可持续性与可维护性考虑,我们不接受如下类型的更改。 +如果您提交的 PR 包含以下类型的更改,我们可能会包括但不限于忽略、关闭或要求您更改 PR 内容。 + +- 导致项目整体性能下降的更改; +- 仅修改注释、空格、格式的小 PR; +- 仅修正无影响力的拼写错误(typo)或代码注释,不提升可读性或准确性; +- 重构已稳定工作的逻辑而不带来可维护性或功能上的实质提升; +- 未经讨论的接口或 API 命名改动; +- 为了获取社区徽章而提交的大量无实际意义的更改。 + +**请注意:判断标准不是改动大小,而是改动是否有实际作用。** + +为提高协作效率,我们建议您在提交 PR 前,先通过 Issue 简要说明动机与背景。 + +## 在编写代码前 + +1. 请仔细阅读[贡献者许可协议](/CLA.md),您提交 PR 默认您已经阅读并同意该协议。 +2. 请预先构想您的更改,并确保它不在**我们不接受的更改**列表中。 + +## 贡献步骤 + +1. **Fork 本仓库** 并创建您的分支(建议使用有意义的分支名)。 +2. **编写代码**,确保遵循项目的代码风格和最佳实践。 +3. **添加/更新测试**,确保您的更改不会破坏现有功能。 +4. **本地测试**,确认所有测试通过。 +5. **提交 Pull Request**,请详细描述您的更改内容和动机。 + + +## 代码规范 + +### 通用 + +所有 PR ,需至少有一位具有写权限的协作者 Approve 后再合并。 + +### 后端 + +**基础检查** + +需要通过 CodeQL 扫描,较长的代码建议增加 Copilot 检查。 + +**API 文档** + +所有接口需要写 Swagger 文档,提交前通过 make swagger 更新文档后再提交。 + +**响应格式** + +```json +# 响应数据最外层有两个字段,error_msg 和 data +{ + "error_msg": "", + "data": null +} + +# 如果是非列表数据 +{ + "error_msg": "", + "data": {} +} + +# 如果是分页数据 +{ + "error_msg": "", + "data": { + "total": 0, + "results": [] + } +} +``` + +**数据库** + +- 禁止使用外键,但需要保留对应字段的索引; +- 字段如有默认值,需要与 struct 默认值相同,如 nil,0,false,空字符串等,避免初始化时未填写或漏填写导致的数据异常。 + +### 前端 + +**基础检查** + +代码需要通过 ESLint 检查和 CodeQL 扫描。 + +**类型安全** + +- 禁止使用 `any` 类型,`any` 类型绕过了 TypeScript 的类型检查系统,会导致潜在的运行时错误; +- `unknown` 是类型安全的 `any`,但必须立即进行类型断言或类型收窄; +- `never` 类型表示永远不会发生的值类型,必须谨慎使用,并提供清晰的注释说明。 + +**组件规范** + +- 组件应按功能分类 +- 公共组件放在 `components/common` 目录 +- ShadcnUI 组件放在 `components/ui` 目录 +- 自定义图标应放置在 `/components/icons/` 目录下以命名导出形式管理,对于常规的图标,我们使用 Lucide 库 + +**服务层** + +服务层架构是前端与API交互的统一入口,基于以下原则: +1. 关注点分离 - 每个服务负责一个业务领域 +2. 统一入口 - 通过services对象导出所有服务 +3. 类型安全 - 所有请求和响应有明确类型定义 + + +**如何新建接口服务** + +1. **创建目录结构**: + ``` + /services/新服务名/ + - types.ts // 类型定义 + - 服务名.service.ts // 服务实现 + - index.ts // 导出服务 + ``` + +2. **实现服务类**: + ```typescript + // 新服务名/服务名.service.ts + import {BaseService} from '../core/base.service'; + + export class 新服务类 extends BaseService { + protected static readonly basePath = '/api/v1/路径'; + + static async 方法名(参数): Promise<返回类型> { + return this.get<返回类型>('/endpoint'); + } + } + ``` + +3. **在services/index.ts注册**: + ```typescript + import {新服务类} from './新服务名'; + + const services = { + auth: AuthService, + 新服务名: 新服务类 + }; + ``` + +**使用方法** + +```typescript +import services from '@/lib/services'; + +// 调用服务方法 +const 结果 = await services.新服务名.方法名(参数); +``` \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..32ed842f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM alpine:3.23 + +# set the time zone to Beijing Time in the Eastern 8th Time Zone +ARG TZ=Asia/Shanghai +RUN apk add --no-cache tzdata && \ + cp /usr/share/zoneinfo/${TZ} /etc/localtime && \ + echo "${TZ}" > /etc/timezone + +WORKDIR /app + +# set build arg for platform +ARG TARGETPLATFORM + +# copy the appropriate binary based on platform +COPY credit-server-${TARGETPLATFORM#linux/} ./credit-server + +# copy docs +COPY docs ./docs + +EXPOSE 8000 + +# set entrypoint +ENTRYPOINT ["./credit-server"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..1b5e2d67 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 linux.do + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..e43aa45e --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +swagger: + scripts/swagger.sh + +tidy: + scripts/tidy.sh + +check_license: + scripts/license.sh + +pre_commit: tidy swagger check_license diff --git a/README.md b/README.md new file mode 100644 index 00000000..ec19b726 --- /dev/null +++ b/README.md @@ -0,0 +1,284 @@ +# LINUX DO Credit + +🚀 Linux Do Community Credit Service Platform + +[中文](./README_zh.md) + +[![License: Apache2.0](https://img.shields.io/badge/License-Apache2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Go Version](https://img.shields.io/badge/Go-1.25.5-blue.svg)](https://golang.org/) +[![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org/) +[![React](https://img.shields.io/badge/React-19-blue.svg)](https://reactjs.org/) + +[![GitHub release](https://img.shields.io/github/v/release/linux-do/credit?include_prereleases)](https://github.com/linux-do/credit/releases) +[![GitHub stars](https://img.shields.io/github/stars/linux-do/credit)](https://github.com/linux-do/credit/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/linux-do/credit)](https://github.com/linux-do/credit/network) +[![GitHub issues](https://img.shields.io/github/issues/linux-do/credit)](https://github.com/linux-do/credit/issues) +[![GitHub pull requests](https://img.shields.io/github/issues-pr/linux-do/credit)](https://github.com/linux-do/credit/pulls) +[![GitHub contributors](https://img.shields.io/github/contributors/linux-do/credit)](https://github.com/linux-do/credit/graphs/contributors) + +[![Backend Build](https://github.com/linux-do/credit/actions/workflows/build_backend.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_backend.yml) +[![Frontend Build](https://github.com/linux-do/credit/actions/workflows/build_frontend.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_frontend.yml) +[![Docker Build](https://github.com/linux-do/credit/actions/workflows/build_image.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_image.yml) +[![CodeQL](https://github.com/linux-do/credit/actions/workflows/codeql.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/codeql.yml) +[![ESLint](https://github.com/linux-do/credit/actions/workflows/eslint.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/eslint.yml) + +## 📖 Introduction + +LINUX DO Credit is a credit service platform built for the Linux Do community, aimed at providing a series of credit-related services and offering a foundational framework for credit circulation for community developers. + +### ✨ Key Features + +- 🔐 **OAuth2 Authentication** - Integrated with Linux Do community account system +- 🛡️ **Risk Control** - Comprehensive trust level and risk assessment system +- 📊 **Real-time Monitoring** - Detailed distribution statistics and user behavior analysis +- 🎨 **Modern Interface** - Responsive design based on Next.js 16 and React 19 +- ⚡ **High Performance** - Go Backend + Redis Cache + PostgreSQL Database + +## 🏗️ Architecture Overview + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Frontend │ │ Backend │ │ Database │ +│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │ +│ │ │ │ │ │ +│ • React 19 │ │ • Gin Framework │ │ • PostgreSQL │ +│ • TypeScript │ │ • OAuth2 │ │ • Redis Cache │ +│ • Tailwind CSS │ │ • Session Store │ │ │ +│ • Shadcn UI │ │ • OpenTelemetry │ │ │ +│ │ │ • Swagger API │ │ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +## 🛠️ Tech Stack + +### Backend +- **[Go 1.25.5](https://go.dev/doc)** - Primary development language +- **[GIN](https://github.com/gin-gonic/gin)** - Web Framework +- **[GORM](https://github.com/go-gorm/gorm)** - ORM Framework +- **[Redis](https://github.com/redis/redis)** - Cache and session store +- **[PostgreSQL](https://www.postgresql.org)** - Primary Database +- **[OpenTelemetry](https://opentelemetry.io)** - Observability +- **[Swagger](https://github.com/swaggo/swag)** - API Documentation + +### Frontend +- **[Next.js 16](https://github.com/vercel/next.js)** - React Framework +- **[React 19](https://github.com/facebook/react)** - UI Library +- **[TypeScript](https://github.com/microsoft/TypeScript)** - Type Safety +- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** - Styling Framework +- **[Shadcn UI](https://github.com/shadcn-ui/ui)** - Component Library +- **[Lucide Icons](https://github.com/lucide-icons/lucide)** - Icon Library + +## 📋 Requirements + +- **Go** >= 1.25.5 +- **Node.js** >= 18.0 +- **PostgreSQL** >= 18 +- **Redis** >= 6.0 +- **pnpm** >= 8.0 (Recommended) + +## 🚀 Quick Start + +### 1. Clone the Project + +```bash +git clone https://github.com/linux-do/credit.git +cd credit +``` + +### 2. Configure Environment + +Copy the configuration file and edit it: + +```bash +cp config.example.yaml config.yaml +``` + +Edit `config.yaml` to configure database connections, Redis, OAuth2, etc. + +### 3. Initialize Database + +```bash +# Create database +createdb -h -p 5432 -U postgres linux_do_credit + +# If you need to specify encoding, use: +# psql -h -p 5432 -U postgres -c "CREATE DATABASE linux_do_credit WITH ENCODING 'UTF8' LC_COLLATE='zh_CN.UTF-8' LC_CTYPE='zh_CN.UTF-8' TEMPLATE template0;" + +# Run migrations (automatically executed when starting the backend) +``` + +### 4. Start Backend + +```bash +# Install Go dependencies +go mod tidy + +# Generate API documentation +make swagger + +# Start backend service +go run main.go api +``` + +### 5. Start Frontend + +```bash +cd frontend + +# Install dependencies +pnpm install + +# Start development server +pnpm dev +``` + +### 6. Access Application + +- **Frontend Interface**: http://localhost:3000 +- **API Documentation**: http://localhost:8000/swagger/index.html +- **Health Check**: http://localhost:8000/api/health + +## ⚙️ Configuration + +### Main Configuration Options + +| Option | Description | Example | +|--------|-------------|---------| +| `app.addr` | Backend service listening address | `:8000` | +| `oauth2.client_id` | OAuth2 Client ID | `your_client_id` | +| `database.host` | PostgreSQL database host | `127.0.0.1` | +| `database.port` | PostgreSQL database port | `5432` | +| `database.username` | PostgreSQL database username | `postgres` | +| `database.password` | PostgreSQL database password | `password` | +| `database.database` | PostgreSQL database name | `linux_do_credit` | +| `database.ssl_mode` | PostgreSQL SSL mode | `disable` | +| `database.application_name` | PostgreSQL application name | `credit-server` | +| `database.search_path` | PostgreSQL search path | `public` | +| `database.default_query_exec_mode` | SQL cache mode | `cache_statement` | +| `redis.host` | Redis server address | `127.0.0.1` | + +For detailed configuration instructions, please refer to the `config.example.yaml` file. + +## 🔧 Development Guide + +### Backend Development + +```bash +# Run API server +go run main.go api + +# Run task scheduler +go run main.go scheduler + +# Run worker queue +go run main.go worker + +# Generate Swagger documentation +make swagger + +# Format and check code +make tidy +``` + +### Frontend Development + +```bash +cd frontend + +# Development mode (using Turbopack) +pnpm dev + +# Build production version +pnpm build + +# Start production service +pnpm start + +# Lint and format code +pnpm lint +pnpm format +``` + +## 📚 API Documentation + +API documentation is automatically generated by Swagger and can be accessed after starting the backend service: + +``` +http://localhost:8000/swagger/index.html +``` + +## 🧪 Testing + +```bash +# Backend testing +go test ./... + +# Frontend testing +cd frontend +pnpm test +``` + +## 🚀 Deployment + +### Docker Deployment + +```bash +# Build image +docker build -t linux-do-credit . + +# Run container +docker run -d -p 8000:8000 linux-do-credit +``` + +### Production Environment Deployment + +1. Build frontend resources: + ```bash + cd frontend && pnpm build + ``` + +2. Compile backend program: + ```bash + go build -o credit main.go + ``` + +3. Configure `config.yaml` for production + +4. Start service: + ```bash + ./credit api + ``` + +## 🤝 Contribution Guidelines + +We welcome community contributions! Please read the following before submitting code: + +- [Contribution Guidelines](CONTRIBUTING.md) +- [Code of Conduct](CODE_OF_CONDUCT.md) +- [Contributor License Agreement](CLA.md) + +### Submission Process + +1. Fork this repository +2. Create a feature branch (`git checkout -b feature/your-feature`) +3. Commit changes (`git commit -am 'Add your feature'`) +4. Push to branch (`git push origin feature/your-feature`) +5. Create Pull Request + +## 📄 License + +This project is open source under the [Apache2.0 License](LICENSE). + +## 🔗 Related Links + +- [Linux Do Community](https://linux.do) +- [Issue Reporting](https://github.com/linux-do/credit/issues) +- [Feature Request](https://github.com/linux-do/credit/issues/new?template=feature_request.md) + +## ❤️ Acknowledgements + +Thanks to all developers who contributed to this project and the support of the Linux Do Community! + +## 📈 Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=linux-do/credit&type=Date)](https://star-history.com/#linux-do/credit&Date) diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 00000000..58a0ed9e --- /dev/null +++ b/README_zh.md @@ -0,0 +1,284 @@ +# LINUX DO Credit + +🚀 Linux Do 社区 Credit 积分服务平台 + +[English](./README.md) + +[![License: Apache2.0](https://img.shields.io/badge/License-Apache2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Go Version](https://img.shields.io/badge/Go-1.25.5-blue.svg)](https://golang.org/) +[![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org/) +[![React](https://img.shields.io/badge/React-19-blue.svg)](https://reactjs.org/) + +[![GitHub release](https://img.shields.io/github/v/release/linux-do/credit?include_prereleases)](https://github.com/linux-do/credit/releases) +[![GitHub stars](https://img.shields.io/github/stars/linux-do/credit)](https://github.com/linux-do/credit/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/linux-do/credit)](https://github.com/linux-do/credit/network) +[![GitHub issues](https://img.shields.io/github/issues/linux-do/credit)](https://github.com/linux-do/credit/issues) +[![GitHub pull requests](https://img.shields.io/github/issues-pr/linux-do/credit)](https://github.com/linux-do/credit/pulls) +[![GitHub contributors](https://img.shields.io/github/contributors/linux-do/credit)](https://github.com/linux-do/credit/graphs/contributors) + +[![Backend Build](https://github.com/linux-do/credit/actions/workflows/build_backend.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_backend.yml) +[![Frontend Build](https://github.com/linux-do/credit/actions/workflows/build_frontend.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_frontend.yml) +[![Docker Build](https://github.com/linux-do/credit/actions/workflows/build_image.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_image.yml) +[![CodeQL](https://github.com/linux-do/credit/actions/workflows/codeql.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/codeql.yml) +[![ESLint](https://github.com/linux-do/credit/actions/workflows/eslint.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/eslint.yml) + +## 📖 项目简介 + +LINUX DO Credit 是一个为 Linux Do 社区打造的积分服务平台,旨在提供一系列积分相关服务,为社区开发者提供积分流转基础框架。 + +### ✨ 主要特性 + +- 🔐 **OAuth2 认证** - 集成 Linux Do 社区账号系统 +- 🛡️ **风险控制** - 完善的信任等级和风险评估系统 +- 📊 **实时监控** - 详细的分发统计和用户行为分析 +- 🎨 **现代化界面** - 基于 Next.js 16 和 React 19 的响应式设计 +- ⚡ **高性能** - Go 后端 + Redis 缓存 + PostgreSQL 数据库 + +## 🏗️ 架构概览 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Frontend │ │ Backend │ │ Database │ +│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │ +│ │ │ │ │ │ +│ • React 19 │ │ • Gin Framework │ │ • PostgreSQL │ +│ • TypeScript │ │ • OAuth2 │ │ • Redis Cache │ +│ • Tailwind CSS │ │ • Session Store │ │ │ +│ • Shadcn UI │ │ • OpenTelemetry │ │ │ +│ │ │ • Swagger API │ │ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +## 🛠️ 技术栈 + +### 后端 +- **[Go 1.25.5](https://go.dev/doc)** - 主要开发语言 +- **[GIN](https://github.com/gin-gonic/gin)** - Web 框架 +- **[GORM](https://github.com/go-gorm/gorm)** - ORM 框架 +- **[Redis](https://github.com/redis/redis)** - 缓存和会话存储 +- **[PostgreSQL](https://www.postgresql.org)** - 主数据库 +- **[OpenTelemetry](https://opentelemetry.io)** - 可观测性 +- **[Swagger](https://github.com/swaggo/swag)** - API 文档 + +### 前端 +- **[Next.js 16](https://github.com/vercel/next.js)** - React 框架 +- **[React 19](https://github.com/facebook/react)** - UI 库 +- **[TypeScript](https://github.com/microsoft/TypeScript)** - 类型安全 +- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** - 样式框架 +- **[Shadcn UI](https://github.com/shadcn-ui/ui)** - 组件库 +- **[Lucide Icons](https://github.com/lucide-icons/lucide)** - 图标库 + +## 📋 环境要求 + +- **Go** >= 1.25.5 +- **Node.js** >= 18.0 +- **PostgreSQL** >= 18 +- **Redis** >= 6.0 +- **pnpm** >= 8.0 (推荐) + +## 🚀 快速开始 + +### 1. 克隆项目 + +```bash +git clone https://github.com/linux-do/credit.git +cd credit +``` + +### 2. 配置环境 + +复制配置文件并编辑: + +```bash +cp config.example.yaml config.yaml +``` + +编辑 `config.yaml` 文件,配置数据库连接、Redis、OAuth2 等信息。 + +### 3. 初始化数据库 + +```bash +# 创建数据库 +createdb -h <主机> -p 5432 -U postgres linux_do_credit + +# 如果需要指定字符集,可使用 +# psql -h <主机> -p 5432 -U postgres -c "CREATE DATABASE linux_do_credit WITH ENCODING 'UTF8' LC_COLLATE='zh_CN.UTF-8' LC_CTYPE='zh_CN.UTF-8' TEMPLATE template0;" + +# 运行迁移(启动后端时会自动执行) +``` + +### 4. 启动后端 + +```bash +# 安装 Go 依赖 +go mod tidy + +# 生成 API 文档 +make swagger + +# 启动后端服务 +go run main.go api +``` + +### 5. 启动前端 + +```bash +cd frontend + +# 安装依赖 +pnpm install + +# 启动开发服务器 +pnpm dev +``` + +### 6. 访问应用 + +- **前端界面**: http://localhost:3000 +- **API 文档**: http://localhost:8000/swagger/index.html +- **健康检查**: http://localhost:8000/api/health + +## ⚙️ 配置说明 + +### 主要配置项 + +| 配置项 | 说明 | 示例 | +|--------|------|------| +| `app.addr` | 后端服务监听地址 | `:8000` | +| `oauth2.client_id` | OAuth2 客户端 ID | `your_client_id` | +| `database.host` | PostgreSQL 数据库地址 | `127.0.0.1` | +| `database.port` | PostgreSQL 数据库端口 | `5432` | +| `database.username` | PostgreSQL 数据库用户名 | `postgres` | +| `database.password` | PostgreSQL 数据库密码 | `password` | +| `database.database` | PostgreSQL 数据库名称 | `linux_do_credit` | +| `database.ssl_mode` | PostgreSQL SSL 模式 | `disable` | +| `database.application_name` | PostgreSQL 应用标识 | `credit-server` | +| `database.search_path` | PostgreSQL 搜索路径 | `public` | +| `database.default_query_exec_mode` | SQL 缓存模式 | `cache_statement` | +| `redis.host` | Redis 服务器地址 | `127.0.0.1` | + +详细配置说明请参考 `config.example.yaml` 文件。 + +## 🔧 开发指南 + +### 后端开发 + +```bash +# 运行 API 服务器 +go run main.go api + +# 运行任务调度器 +go run main.go scheduler + +# 运行工作队列 +go run main.go worker + +# 生成 Swagger 文档 +make swagger + +# 代码格式化和检查 +make tidy +``` + +### 前端开发 + +```bash +cd frontend + +# 开发模式(使用 Turbopack) +pnpm dev + +# 构建生产版本 +pnpm build + +# 启动生产服务 +pnpm start + +# 代码检查和格式化 +pnpm lint +pnpm format +``` + +## 📚 API 文档 + +API 文档通过 Swagger 自动生成,启动后端服务后可访问: + +``` +http://localhost:8000/swagger/index.html +``` + +## 🧪 测试 + +```bash +# 后端测试 +go test ./... + +# 前端测试 +cd frontend +pnpm test +``` + +## 🚀 部署 + +### Docker 部署 + +```bash +# 构建镜像 +docker build -t linux-do-credit . + +# 运行容器 +docker run -d -p 8000:8000 linux-do-credit +``` + +### 生产环境部署 + +1. 构建前端资源: + ```bash + cd frontend && pnpm build + ``` + +2. 编译后端程序: + ```bash + go build -o credit main.go + ``` + +3. 配置生产环境的 `config.yaml` + +4. 启动服务: + ```bash + ./credit api + ``` + +## 🤝 贡献指南 + +我们欢迎社区贡献!请在提交代码前阅读: + +- [贡献指南](CONTRIBUTING.md) +- [行为准则](CODE_OF_CONDUCT.md) +- [贡献者许可协议](CLA.md) + +### 提交流程 + +1. Fork 本仓库 +2. 创建特性分支 (`git checkout -b feature/your-feature`) +3. 提交更改 (`git commit -am 'Add your feature'`) +4. 推送到分支 (`git push origin feature/your-feature`) +5. 创建 Pull Request + +## 📄 许可证 + +本项目基于 [Apache2.0 许可证](LICENSE) 开源。 + +## 🔗 相关链接 + +- [Linux Do 社区](https://linux.do) +- [问题反馈](https://github.com/linux-do/credit/issues) +- [功能请求](https://github.com/linux-do/credit/issues/new?template=feature_request.md) + +## ❤️ 致谢 + +感谢所有为本项目做出贡献的开发者和 Linux Do 社区的支持! + +## 📈 项目趋势 + +[![Star History Chart](https://api.star-history.com/svg?repos=linux-do/credit&type=Date)](https://star-history.com/#linux-do/credit&Date) diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 00000000..91176135 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,164 @@ +# LINUX DO Credit Config +# Copy to config.yaml for runtime + +# App +app: + app_name: "linux-do-credit" + env: "development" # development, testing, production + addr: ":8000" + node_id: 1 # 分布式节点 ID (0-1023),不同实例必须不同 + graceful_shutdown_timeout: 30 + session_cookie_name: "linux_do_credit_session_id" # change this in local dev env + session_secret: "" # you can't change this after first time start + session_domain: ".linux.do" + session_age: 86400 + session_secure: false + session_http_only: false + api_prefix: "/api" + frontend_url: "http://localhost:3000" + frontend_pay_url: "http://localhost:3000/paying" + +# OAuth2/OIDC(优先) +oauth2: + client_id: "" + client_secret: "" + redirect_uri: "" + issuer: "https://connect.linux.do/" # OIDC Issuer URL,用于自动发现端点 + authorization_endpoint: "https://connect.linux.do/oauth2/authorize" + token_endpoint: "https://connect.linux.do/oauth2/token" + user_endpoint: "https://connect.linux.do/api/user" + +# DB +# 支持两种模式:Standalone(单节点)、Primary-Replica(读写分离) +database: + enabled: true + host: "127.0.0.1" + port: 5432 + username: "postgres" + password: "" + database: "linux_do_credit" + max_idle_conn: 16 + max_open_conn: 128 + conn_max_lifetime: 1800 + conn_max_idle_time: 600 + log_level: "info" # error, warn, info, debug, silent + ssl_mode: "disable" + time_zone: "Asia/Shanghai" + application_name: "pay-server" + # pgb改为true,默认false + prefer_simple_protocol: false + search_path: "public" + statement_cache_capacity: 256 + default_query_exec_mode: "cache_statement" + slow_threshold: 200ms + # replicas: + # - host: "replica1.db.local" + # port: 5432 + # - host: "replica2.db.local" + # port: 5432 + +# clickhouse +clickhouse: + enabled: false + hosts: + - "127.0.0.1:9000" + username: "default" + password: "" + database: "linux_do_credit" + max_idle_conn: 10 + max_open_conn: 100 + conn_max_lifetime: 3600 + dial_timeout: 5 + block_buffer_size: 10 + +# Redis +# 支持三种模式:Standalone(单节点)、Sentinel(高可用)、Cluster(水平扩展) +redis: + enabled: true + addrs: + - "127.0.0.1:6379" + username: "" + password: "" + db: 0 # Cluster 模式忽略此项 + cluster_mode: false # true 启用 Cluster 模式 + master_name: "" # 非空启用 Sentinel 模式 + key_prefix: "credit:" + pool_size: 100 + min_idle_conn: 10 + dial_timeout: 5 + read_timeout: 3 + write_timeout: 3 + max_retries: 3 + pool_timeout: 4 + conn_max_idle_time: 300 + +# Log +log: + level: "info" # debug, info, warn, error, fatal, panic + format: "json" # text, json + output: "stdout" # stdout, file + file_path: "./logs/app.log" + max_size: 100 + max_age: 30 + max_backups: 10 + compress: true + +# Scheduler +scheduler: + update_user_gamification_scores_task_cron: "0 2 * * *" + dispute_auto_refund_dispatch_interval_seconds: 3 + auto_refund_expired_disputes_task_cron: "0 0 * * *" + sync_orders_to_clickhouse_task_cron: "10 0 * * *" + refund_expired_red_envelopes_task_cron: "0 1 * * *" + cleanup_unused_uploads_task_cron: "0 */2 * * *" + settle_pending_payments_task_cron: "0 * * * *" + +# Worker +worker: + concurrency: 20 + strict_priority: false + queues: + - name: webhook + priority: 10 + - name: whitelist_only + priority: 5 + - name: default + priority: 3 + # 积分更新速率限制:rate 次/period 秒 + gamification_score_rate_limit: + rate: 1 # 允许的请求次数 + period: 3 # 时间周期(秒) + +# linuxDo +linuxDo: + api_key: "" + +# OpenAPI Risk +openapi_risk: + enabled: false + base_url: "https://audit.example.com" + username: "" + password: "" + cache_ttl_seconds: 3600 + prompt_risk_levels: [] + block_risk_levels: [] + +# OpenTelemetry +otel: + sampling_rate: 0.1 # 采样率 0.0-1.0 + +# S3 Compatible Storage +# 支持 AWS S3、MinIO、Cloudflare R2、腾讯 COS 等 S3 兼容存储 +s3: + enabled: true + endpoint: "https://.r2.cloudflarestorage.com" + region: "auto" + bucket: "" + access_key_id: "" + secret_access_key: "" + path_style: false # MinIO 等自托管服务设为 true + key_prefix: "" # 对象 key 前缀,如 "uploads/",可用于分目录存储 + cdn_url: "" # CDN 域名(如 https://cdn.example.com),为空则直接读 S3 + local_cache: + enabled: false + cache_dir: "./s3_cache" diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 00000000..6c5a06cf --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,2566 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api.php": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "type": "string", + "name": "act", + "in": "query" + }, + { + "type": "string", + "name": "key", + "in": "query", + "required": true + }, + { + "maxLength": 64, + "minLength": 1, + "type": "string", + "name": "out_trade_no", + "in": "query", + "required": true + }, + { + "type": "string", + "name": "pid", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/payment.QueryMerchantOrderResponse" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "description": "退款请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/payment.RefundOrderRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/payment.RefundMerchantOrderResponse" + } + } + } + } + }, + "/api/v1/admin/system-configs": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/system_config.CreateSystemConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/system-configs/{key}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "string", + "description": "配置键", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "string", + "description": "配置键", + "name": "key", + "in": "path", + "required": true + }, + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/system_config.UpdateSystemConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "string", + "description": "配置键", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/tasks/dispatch": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/task.DispatchTaskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/tasks/types": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/user-pay-configs": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user_pay_config.CreateUserPayConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/user-pay-configs/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "string", + "description": "配置ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "string", + "description": "配置ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user_pay_config.UpdateUserPayConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "string", + "description": "配置ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/users": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "page_size", + "in": "query" + }, + { + "type": "integer", + "name": "user_id", + "in": "query" + }, + { + "type": "string", + "name": "username", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/users/{id}/status": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "状态", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.updateUserStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/config/public": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "config" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/dashboard/stats/daily": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "获取每日收支统计", + "parameters": [ + { + "type": "integer", + "description": "查询天数,最大7天", + "name": "days", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/dashboard/stats/top-customers": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "获取Top客户", + "parameters": [ + { + "type": "integer", + "description": "查询天数,最大7天", + "name": "days", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "返回数量,最大10", + "name": "limit", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/dashboard/stats/user-balance": { + "get": { + "description": "统计所有用户的AvailableBalance字段", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "获取用户余额统计", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/dashboard.UserBalanceStatsResponse" + } + } + } + ] + } + } + } + } + }, + "/api/v1/health": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/leaderboard": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "leaderboard" + ], + "summary": "获取排行榜列表", + "parameters": [ + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "每页数量", + "name": "page_size", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/leaderboard/me": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "leaderboard" + ], + "summary": "获取当前用户排名", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/leaderboard/users/{id}": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "leaderboard" + ], + "summary": "获取指定用户排名", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/api-keys": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api_key.CreateAPIKeyRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/api-keys/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api_key.UpdateAPIKeyRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/api-keys/{id}/payment-links": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "创建支付链接请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/link.PaymentLinkRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/api-keys/{id}/payment-links/{linkId}": { + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "Payment Link ID", + "name": "linkId", + "in": "path", + "required": true + }, + { + "description": "更新支付链接请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/link.PaymentLinkRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "Payment Link ID", + "name": "linkId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/payment": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "description": "支付订单请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/payment.PayOrderRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/payment-links/pay": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "description": "支付请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/link.PayByLinkRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/payment-links/{token}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "string", + "description": "支付链接 Token", + "name": "token", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/payment/order": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "type": "string", + "description": "订单号", + "name": "order_no", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/oauth/callback": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oauth.CallbackRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/oauth/login": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/oauth/logout": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/oauth/user-info": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/order/dispute": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dispute.CreateDisputeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/order/dispute/close": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dispute.CloseDisputeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/order/disputes": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/dispute.ListDisputesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/order/disputes/merchant": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/dispute.ListDisputesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/order/refund-review": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dispute.RefundReviewRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/order/transactions": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/order.TransactionListRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/payment/transfer": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "description": "转账请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/payment.TransferRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/claim": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "description": "领取红包请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/redenvelope.ClaimRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/covers": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "type": "string", + "description": "封面类型 (cover/heterotypic)", + "name": "type", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/create": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "description": "创建红包请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/redenvelope.CreateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/list": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "description": "列表请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/redenvelope.ListRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "type": "string", + "description": "红包ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/upload/redenvelope/cover": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "parameters": [ + { + "type": "file", + "description": "图片文件", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "封面类型 (cover/heterotypic)", + "name": "type", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/pay-key": { + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.UpdatePayKeyRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/f/{id}": { + "get": { + "produces": [ + "application/octet-stream" + ], + "tags": [ + "upload" + ], + "parameters": [ + { + "type": "string", + "description": "Upload ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/pay/distribute": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "type": "string", + "description": "Basic Auth (base64(client_id:client_secret))", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "分发请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/payment.MerchantDistributeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/pay/submit.php": { + "get": { + "consumes": [ + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/payment.CreateOrderRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "post": { + "consumes": [ + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/payment.CreateOrderRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + } + }, + "definitions": { + "api_key.CreateAPIKeyRequest": { + "type": "object", + "required": [ + "app_homepage_url", + "app_name", + "notify_url" + ], + "properties": { + "app_description": { + "type": "string", + "maxLength": 100 + }, + "app_homepage_url": { + "type": "string", + "maxLength": 100 + }, + "app_name": { + "type": "string", + "maxLength": 20 + }, + "notify_url": { + "type": "string", + "maxLength": 100 + }, + "public_key": { + "type": "string", + "maxLength": 100 + }, + "redirect_uri": { + "type": "string", + "maxLength": 100 + }, + "test_mode": { + "type": "boolean" + } + } + }, + "api_key.UpdateAPIKeyRequest": { + "type": "object", + "properties": { + "app_description": { + "type": "string", + "maxLength": 100 + }, + "app_homepage_url": { + "type": "string", + "maxLength": 100 + }, + "app_name": { + "type": "string", + "maxLength": 20 + }, + "notify_url": { + "type": "string", + "maxLength": 100 + }, + "public_key": { + "type": "string", + "maxLength": 100 + }, + "redirect_uri": { + "type": "string", + "maxLength": 100 + }, + "test_mode": { + "type": "boolean" + } + } + }, + "dashboard.UserBalanceStatsResponse": { + "type": "object", + "properties": { + "avg_amount": { + "type": "number" + }, + "max_amount": { + "type": "number" + }, + "median_amount": { + "type": "number" + }, + "min_amount": { + "type": "number" + }, + "std_dev": { + "type": "number" + }, + "total_amount": { + "type": "number" + }, + "total_count": { + "type": "integer" + } + } + }, + "dispute.CloseDisputeRequest": { + "type": "object", + "required": [ + "dispute_id" + ], + "properties": { + "dispute_id": { + "type": "string", + "example": "0" + } + } + }, + "dispute.CreateDisputeRequest": { + "type": "object", + "required": [ + "order_id", + "reason" + ], + "properties": { + "order_id": { + "type": "string", + "example": "0" + }, + "reason": { + "type": "string", + "maxLength": 100 + } + } + }, + "dispute.ListDisputesRequest": { + "type": "object", + "properties": { + "dispute_id": { + "type": "string", + "example": "0" + }, + "page": { + "type": "integer", + "minimum": 1 + }, + "page_size": { + "type": "integer", + "maximum": 100, + "minimum": 1 + }, + "status": { + "type": "string", + "enum": [ + "disputing", + "refund", + "closed" + ] + } + } + }, + "dispute.RefundReviewRequest": { + "type": "object", + "required": [ + "dispute_id", + "status" + ], + "properties": { + "dispute_id": { + "type": "string", + "example": "0" + }, + "reason": { + "type": "string", + "maxLength": 100 + }, + "status": { + "type": "string", + "enum": [ + "refund", + "closed" + ] + } + } + }, + "link.PayByLinkRequest": { + "type": "object", + "required": [ + "pay_key", + "token" + ], + "properties": { + "pay_key": { + "type": "string", + "maxLength": 6 + }, + "remark": { + "type": "string", + "maxLength": 100 + }, + "token": { + "type": "string" + } + } + }, + "link.PaymentLinkRequest": { + "type": "object", + "required": [ + "amount", + "product_name" + ], + "properties": { + "amount": { + "type": "number" + }, + "product_name": { + "type": "string", + "maxLength": 30 + }, + "remark": { + "type": "string", + "maxLength": 100 + }, + "total_limit": { + "type": "integer", + "minimum": 1 + }, + "user_limit": { + "type": "integer", + "minimum": 1 + } + } + }, + "model.OrderTransferStatus": { + "type": "string", + "enum": [ + "pending", + "completed" + ], + "x-enum-varnames": [ + "OrderTransferStatusPending", + "OrderTransferStatusCompleted" + ] + }, + "model.PayLevel": { + "type": "integer", + "format": "int32", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-varnames": [ + "PayLevelFree", + "PayLevelBasic", + "PayLevelStandard", + "PayLevelPremium" + ] + }, + "model.RedEnvelopeType": { + "type": "string", + "enum": [ + "fixed", + "random" + ], + "x-enum-varnames": [ + "RedEnvelopeTypeFixed", + "RedEnvelopeTypeRandom" + ] + }, + "oauth.CallbackRequest": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "state": { + "type": "string" + } + } + }, + "order.TransactionListRequest": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "endTime": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "order_name": { + "type": "string" + }, + "page": { + "type": "integer", + "minimum": 1 + }, + "page_size": { + "type": "integer", + "maximum": 100, + "minimum": 1 + }, + "payee_transfer_status": { + "enum": [ + "pending", + "completed" + ], + "allOf": [ + { + "$ref": "#/definitions/model.OrderTransferStatus" + } + ] + }, + "payee_username": { + "type": "string" + }, + "payer_username": { + "type": "string" + }, + "startTime": { + "type": "string" + }, + "statuses": { + "type": "array", + "items": { + "type": "string" + } + }, + "types": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "payment.CreateOrderRequest": { + "type": "object", + "required": [ + "amount", + "order_name" + ], + "properties": { + "amount": { + "type": "number" + }, + "merchant_order_no": { + "type": "string", + "maxLength": 64, + "minLength": 1 + }, + "notify_url": { + "type": "string", + "maxLength": 100 + }, + "order_name": { + "type": "string", + "maxLength": 64 + }, + "payment_type": { + "type": "string" + }, + "remark": { + "type": "string", + "maxLength": 100 + }, + "return_url": { + "type": "string", + "maxLength": 100 + } + } + }, + "payment.MerchantDistributeRequest": { + "type": "object", + "required": [ + "amount", + "user_id", + "username" + ], + "properties": { + "amount": { + "type": "number" + }, + "out_trade_no": { + "type": "string", + "maxLength": 64, + "minLength": 1 + }, + "remark": { + "type": "string", + "maxLength": 100 + }, + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "payment.PayOrderRequest": { + "type": "object", + "required": [ + "order_no", + "pay_key" + ], + "properties": { + "order_no": { + "type": "string" + }, + "pay_key": { + "type": "string", + "maxLength": 6 + } + } + }, + "payment.QueryMerchantOrderResponse": { + "type": "object", + "properties": { + "addtime": { + "type": "string", + "example": "2023-12-08 12:00:00" + }, + "code": { + "type": "integer", + "example": 1 + }, + "endtime": { + "type": "string", + "example": "2023-12-08 12:05:00" + }, + "money": { + "type": "string", + "example": "10.00" + }, + "msg": { + "type": "string", + "example": "查询订单号成功!" + }, + "name": { + "type": "string", + "example": "商品名称" + }, + "out_trade_no": { + "type": "string", + "example": "M202312080001" + }, + "pid": { + "type": "string", + "example": "1001" + }, + "status": { + "type": "integer", + "example": 1 + }, + "trade_no": { + "type": "string", + "example": "123456" + }, + "type": { + "type": "string", + "example": "epay" + } + } + }, + "payment.RefundMerchantOrderResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 1 + }, + "msg": { + "type": "string", + "example": "退款成功" + } + } + }, + "payment.RefundOrderRequest": { + "type": "object", + "required": [ + "key", + "money", + "pid", + "trade_no" + ], + "properties": { + "key": { + "type": "string" + }, + "money": { + "type": "number" + }, + "out_trade_no": { + "type": "string" + }, + "pid": { + "type": "string" + }, + "trade_no": { + "type": "integer" + } + } + }, + "payment.TransferRequest": { + "type": "object", + "required": [ + "amount", + "pay_key", + "recipient_id", + "recipient_username" + ], + "properties": { + "amount": { + "type": "number" + }, + "pay_key": { + "type": "string", + "maxLength": 6 + }, + "recipient_id": { + "type": "string", + "example": "0" + }, + "recipient_username": { + "type": "string" + }, + "remark": { + "type": "string", + "maxLength": 100 + } + } + }, + "redenvelope.ClaimRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "example": "0" + } + } + }, + "redenvelope.CreateRequest": { + "type": "object", + "required": [ + "pay_key", + "total_amount", + "total_count", + "type" + ], + "properties": { + "cover_upload_id": { + "type": "string", + "example": "0" + }, + "greeting": { + "type": "string", + "maxLength": 100 + }, + "heterotypic_upload_id": { + "type": "string", + "example": "0" + }, + "pay_key": { + "type": "string", + "maxLength": 10 + }, + "total_amount": { + "type": "number" + }, + "total_count": { + "type": "integer", + "minimum": 1 + }, + "type": { + "enum": [ + "fixed", + "random" + ], + "allOf": [ + { + "$ref": "#/definitions/model.RedEnvelopeType" + } + ] + } + } + }, + "redenvelope.ListRequest": { + "type": "object", + "required": [ + "page", + "page_size" + ], + "properties": { + "page": { + "type": "integer", + "minimum": 1 + }, + "page_size": { + "type": "integer", + "maximum": 100, + "minimum": 1 + }, + "type": { + "type": "string", + "enum": [ + "sent", + "received" + ] + } + } + }, + "system_config.CreateSystemConfigRequest": { + "type": "object", + "required": [ + "key", + "value" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255 + }, + "key": { + "type": "string", + "maxLength": 64 + }, + "value": { + "type": "string", + "maxLength": 255 + } + } + }, + "system_config.UpdateSystemConfigRequest": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255 + }, + "value": { + "type": "string", + "maxLength": 255 + } + } + }, + "task.DispatchTaskRequest": { + "type": "object", + "required": [ + "task_type" + ], + "properties": { + "end_time": { + "type": "string" + }, + "start_time": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "user.UpdatePayKeyRequest": { + "type": "object", + "required": [ + "pay_key" + ], + "properties": { + "pay_key": { + "type": "string", + "maxLength": 6 + } + } + }, + "user.updateUserStatusRequest": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + } + } + }, + "user_pay_config.CreateUserPayConfigRequest": { + "type": "object", + "required": [ + "distribute_rate", + "fee_rate", + "score_rate" + ], + "properties": { + "daily_limit": { + "type": "integer" + }, + "distribute_rate": { + "type": "number" + }, + "fee_rate": { + "type": "number" + }, + "level": { + "$ref": "#/definitions/model.PayLevel" + }, + "max_score": { + "type": "integer" + }, + "min_score": { + "type": "integer" + }, + "score_rate": { + "type": "number" + } + } + }, + "user_pay_config.UpdateUserPayConfigRequest": { + "type": "object", + "required": [ + "distribute_rate", + "fee_rate", + "score_rate" + ], + "properties": { + "daily_limit": { + "type": "integer" + }, + "distribute_rate": { + "type": "number" + }, + "fee_rate": { + "type": "number" + }, + "max_score": { + "type": "integer" + }, + "min_score": { + "type": "integer" + }, + "score_rate": { + "type": "number" + } + } + }, + "util.ResponseAny": { + "type": "object", + "properties": { + "data": {}, + "error_msg": { + "type": "string", + "example": "" + } + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0.0", + Host: "", + BasePath: "", + Schemes: []string{}, + Title: "LINUX DO Credit", + Description: "", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 00000000..98c4939f --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,2539 @@ +{ + "swagger": "2.0", + "info": { + "title": "LINUX DO Credit", + "contact": {}, + "version": "1.0.0" + }, + "paths": { + "/api.php": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "type": "string", + "name": "act", + "in": "query" + }, + { + "type": "string", + "name": "key", + "in": "query", + "required": true + }, + { + "maxLength": 64, + "minLength": 1, + "type": "string", + "name": "out_trade_no", + "in": "query", + "required": true + }, + { + "type": "string", + "name": "pid", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/payment.QueryMerchantOrderResponse" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "description": "退款请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/payment.RefundOrderRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/payment.RefundMerchantOrderResponse" + } + } + } + } + }, + "/api/v1/admin/system-configs": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/system_config.CreateSystemConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/system-configs/{key}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "string", + "description": "配置键", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "string", + "description": "配置键", + "name": "key", + "in": "path", + "required": true + }, + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/system_config.UpdateSystemConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "string", + "description": "配置键", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/tasks/dispatch": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/task.DispatchTaskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/tasks/types": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/user-pay-configs": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user_pay_config.CreateUserPayConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/user-pay-configs/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "string", + "description": "配置ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "string", + "description": "配置ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user_pay_config.UpdateUserPayConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "string", + "description": "配置ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/users": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "page_size", + "in": "query" + }, + { + "type": "integer", + "name": "user_id", + "in": "query" + }, + { + "type": "string", + "name": "username", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/users/{id}/status": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "状态", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.updateUserStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/config/public": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "config" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/dashboard/stats/daily": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "获取每日收支统计", + "parameters": [ + { + "type": "integer", + "description": "查询天数,最大7天", + "name": "days", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/dashboard/stats/top-customers": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "获取Top客户", + "parameters": [ + { + "type": "integer", + "description": "查询天数,最大7天", + "name": "days", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "返回数量,最大10", + "name": "limit", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/dashboard/stats/user-balance": { + "get": { + "description": "统计所有用户的AvailableBalance字段", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "获取用户余额统计", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/dashboard.UserBalanceStatsResponse" + } + } + } + ] + } + } + } + } + }, + "/api/v1/health": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/leaderboard": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "leaderboard" + ], + "summary": "获取排行榜列表", + "parameters": [ + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "每页数量", + "name": "page_size", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/leaderboard/me": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "leaderboard" + ], + "summary": "获取当前用户排名", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/leaderboard/users/{id}": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "leaderboard" + ], + "summary": "获取指定用户排名", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/api-keys": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api_key.CreateAPIKeyRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/api-keys/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api_key.UpdateAPIKeyRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/api-keys/{id}/payment-links": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "创建支付链接请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/link.PaymentLinkRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/api-keys/{id}/payment-links/{linkId}": { + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "Payment Link ID", + "name": "linkId", + "in": "path", + "required": true + }, + { + "description": "更新支付链接请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/link.PaymentLinkRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "API Key ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "Payment Link ID", + "name": "linkId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/payment": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "description": "支付订单请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/payment.PayOrderRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/payment-links/pay": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "description": "支付请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/link.PayByLinkRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/payment-links/{token}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "merchant" + ], + "parameters": [ + { + "type": "string", + "description": "支付链接 Token", + "name": "token", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/merchant/payment/order": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "type": "string", + "description": "订单号", + "name": "order_no", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/oauth/callback": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oauth.CallbackRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/oauth/login": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/oauth/logout": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/oauth/user-info": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/order/dispute": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dispute.CreateDisputeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/order/dispute/close": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dispute.CloseDisputeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/order/disputes": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/dispute.ListDisputesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/order/disputes/merchant": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/dispute.ListDisputesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/order/refund-review": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dispute.RefundReviewRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/order/transactions": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/order.TransactionListRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/payment/transfer": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "description": "转账请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/payment.TransferRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/claim": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "description": "领取红包请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/redenvelope.ClaimRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/covers": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "type": "string", + "description": "封面类型 (cover/heterotypic)", + "name": "type", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/create": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "description": "创建红包请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/redenvelope.CreateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/list": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "description": "列表请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/redenvelope.ListRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "type": "string", + "description": "红包ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/upload/redenvelope/cover": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "parameters": [ + { + "type": "file", + "description": "图片文件", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "封面类型 (cover/heterotypic)", + "name": "type", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/pay-key": { + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.UpdatePayKeyRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/f/{id}": { + "get": { + "produces": [ + "application/octet-stream" + ], + "tags": [ + "upload" + ], + "parameters": [ + { + "type": "string", + "description": "Upload ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/pay/distribute": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "type": "string", + "description": "Basic Auth (base64(client_id:client_secret))", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "分发请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/payment.MerchantDistributeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/pay/submit.php": { + "get": { + "consumes": [ + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/payment.CreateOrderRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "post": { + "consumes": [ + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json" + ], + "tags": [ + "payment" + ], + "parameters": [ + { + "description": "request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/payment.CreateOrderRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + } + }, + "definitions": { + "api_key.CreateAPIKeyRequest": { + "type": "object", + "required": [ + "app_homepage_url", + "app_name", + "notify_url" + ], + "properties": { + "app_description": { + "type": "string", + "maxLength": 100 + }, + "app_homepage_url": { + "type": "string", + "maxLength": 100 + }, + "app_name": { + "type": "string", + "maxLength": 20 + }, + "notify_url": { + "type": "string", + "maxLength": 100 + }, + "public_key": { + "type": "string", + "maxLength": 100 + }, + "redirect_uri": { + "type": "string", + "maxLength": 100 + }, + "test_mode": { + "type": "boolean" + } + } + }, + "api_key.UpdateAPIKeyRequest": { + "type": "object", + "properties": { + "app_description": { + "type": "string", + "maxLength": 100 + }, + "app_homepage_url": { + "type": "string", + "maxLength": 100 + }, + "app_name": { + "type": "string", + "maxLength": 20 + }, + "notify_url": { + "type": "string", + "maxLength": 100 + }, + "public_key": { + "type": "string", + "maxLength": 100 + }, + "redirect_uri": { + "type": "string", + "maxLength": 100 + }, + "test_mode": { + "type": "boolean" + } + } + }, + "dashboard.UserBalanceStatsResponse": { + "type": "object", + "properties": { + "avg_amount": { + "type": "number" + }, + "max_amount": { + "type": "number" + }, + "median_amount": { + "type": "number" + }, + "min_amount": { + "type": "number" + }, + "std_dev": { + "type": "number" + }, + "total_amount": { + "type": "number" + }, + "total_count": { + "type": "integer" + } + } + }, + "dispute.CloseDisputeRequest": { + "type": "object", + "required": [ + "dispute_id" + ], + "properties": { + "dispute_id": { + "type": "string", + "example": "0" + } + } + }, + "dispute.CreateDisputeRequest": { + "type": "object", + "required": [ + "order_id", + "reason" + ], + "properties": { + "order_id": { + "type": "string", + "example": "0" + }, + "reason": { + "type": "string", + "maxLength": 100 + } + } + }, + "dispute.ListDisputesRequest": { + "type": "object", + "properties": { + "dispute_id": { + "type": "string", + "example": "0" + }, + "page": { + "type": "integer", + "minimum": 1 + }, + "page_size": { + "type": "integer", + "maximum": 100, + "minimum": 1 + }, + "status": { + "type": "string", + "enum": [ + "disputing", + "refund", + "closed" + ] + } + } + }, + "dispute.RefundReviewRequest": { + "type": "object", + "required": [ + "dispute_id", + "status" + ], + "properties": { + "dispute_id": { + "type": "string", + "example": "0" + }, + "reason": { + "type": "string", + "maxLength": 100 + }, + "status": { + "type": "string", + "enum": [ + "refund", + "closed" + ] + } + } + }, + "link.PayByLinkRequest": { + "type": "object", + "required": [ + "pay_key", + "token" + ], + "properties": { + "pay_key": { + "type": "string", + "maxLength": 6 + }, + "remark": { + "type": "string", + "maxLength": 100 + }, + "token": { + "type": "string" + } + } + }, + "link.PaymentLinkRequest": { + "type": "object", + "required": [ + "amount", + "product_name" + ], + "properties": { + "amount": { + "type": "number" + }, + "product_name": { + "type": "string", + "maxLength": 30 + }, + "remark": { + "type": "string", + "maxLength": 100 + }, + "total_limit": { + "type": "integer", + "minimum": 1 + }, + "user_limit": { + "type": "integer", + "minimum": 1 + } + } + }, + "model.OrderTransferStatus": { + "type": "string", + "enum": [ + "pending", + "completed" + ], + "x-enum-varnames": [ + "OrderTransferStatusPending", + "OrderTransferStatusCompleted" + ] + }, + "model.PayLevel": { + "type": "integer", + "format": "int32", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-varnames": [ + "PayLevelFree", + "PayLevelBasic", + "PayLevelStandard", + "PayLevelPremium" + ] + }, + "model.RedEnvelopeType": { + "type": "string", + "enum": [ + "fixed", + "random" + ], + "x-enum-varnames": [ + "RedEnvelopeTypeFixed", + "RedEnvelopeTypeRandom" + ] + }, + "oauth.CallbackRequest": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "state": { + "type": "string" + } + } + }, + "order.TransactionListRequest": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "endTime": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "order_name": { + "type": "string" + }, + "page": { + "type": "integer", + "minimum": 1 + }, + "page_size": { + "type": "integer", + "maximum": 100, + "minimum": 1 + }, + "payee_transfer_status": { + "enum": [ + "pending", + "completed" + ], + "allOf": [ + { + "$ref": "#/definitions/model.OrderTransferStatus" + } + ] + }, + "payee_username": { + "type": "string" + }, + "payer_username": { + "type": "string" + }, + "startTime": { + "type": "string" + }, + "statuses": { + "type": "array", + "items": { + "type": "string" + } + }, + "types": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "payment.CreateOrderRequest": { + "type": "object", + "required": [ + "amount", + "order_name" + ], + "properties": { + "amount": { + "type": "number" + }, + "merchant_order_no": { + "type": "string", + "maxLength": 64, + "minLength": 1 + }, + "notify_url": { + "type": "string", + "maxLength": 100 + }, + "order_name": { + "type": "string", + "maxLength": 64 + }, + "payment_type": { + "type": "string" + }, + "remark": { + "type": "string", + "maxLength": 100 + }, + "return_url": { + "type": "string", + "maxLength": 100 + } + } + }, + "payment.MerchantDistributeRequest": { + "type": "object", + "required": [ + "amount", + "user_id", + "username" + ], + "properties": { + "amount": { + "type": "number" + }, + "out_trade_no": { + "type": "string", + "maxLength": 64, + "minLength": 1 + }, + "remark": { + "type": "string", + "maxLength": 100 + }, + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "payment.PayOrderRequest": { + "type": "object", + "required": [ + "order_no", + "pay_key" + ], + "properties": { + "order_no": { + "type": "string" + }, + "pay_key": { + "type": "string", + "maxLength": 6 + } + } + }, + "payment.QueryMerchantOrderResponse": { + "type": "object", + "properties": { + "addtime": { + "type": "string", + "example": "2023-12-08 12:00:00" + }, + "code": { + "type": "integer", + "example": 1 + }, + "endtime": { + "type": "string", + "example": "2023-12-08 12:05:00" + }, + "money": { + "type": "string", + "example": "10.00" + }, + "msg": { + "type": "string", + "example": "查询订单号成功!" + }, + "name": { + "type": "string", + "example": "商品名称" + }, + "out_trade_no": { + "type": "string", + "example": "M202312080001" + }, + "pid": { + "type": "string", + "example": "1001" + }, + "status": { + "type": "integer", + "example": 1 + }, + "trade_no": { + "type": "string", + "example": "123456" + }, + "type": { + "type": "string", + "example": "epay" + } + } + }, + "payment.RefundMerchantOrderResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 1 + }, + "msg": { + "type": "string", + "example": "退款成功" + } + } + }, + "payment.RefundOrderRequest": { + "type": "object", + "required": [ + "key", + "money", + "pid", + "trade_no" + ], + "properties": { + "key": { + "type": "string" + }, + "money": { + "type": "number" + }, + "out_trade_no": { + "type": "string" + }, + "pid": { + "type": "string" + }, + "trade_no": { + "type": "integer" + } + } + }, + "payment.TransferRequest": { + "type": "object", + "required": [ + "amount", + "pay_key", + "recipient_id", + "recipient_username" + ], + "properties": { + "amount": { + "type": "number" + }, + "pay_key": { + "type": "string", + "maxLength": 6 + }, + "recipient_id": { + "type": "string", + "example": "0" + }, + "recipient_username": { + "type": "string" + }, + "remark": { + "type": "string", + "maxLength": 100 + } + } + }, + "redenvelope.ClaimRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "example": "0" + } + } + }, + "redenvelope.CreateRequest": { + "type": "object", + "required": [ + "pay_key", + "total_amount", + "total_count", + "type" + ], + "properties": { + "cover_upload_id": { + "type": "string", + "example": "0" + }, + "greeting": { + "type": "string", + "maxLength": 100 + }, + "heterotypic_upload_id": { + "type": "string", + "example": "0" + }, + "pay_key": { + "type": "string", + "maxLength": 10 + }, + "total_amount": { + "type": "number" + }, + "total_count": { + "type": "integer", + "minimum": 1 + }, + "type": { + "enum": [ + "fixed", + "random" + ], + "allOf": [ + { + "$ref": "#/definitions/model.RedEnvelopeType" + } + ] + } + } + }, + "redenvelope.ListRequest": { + "type": "object", + "required": [ + "page", + "page_size" + ], + "properties": { + "page": { + "type": "integer", + "minimum": 1 + }, + "page_size": { + "type": "integer", + "maximum": 100, + "minimum": 1 + }, + "type": { + "type": "string", + "enum": [ + "sent", + "received" + ] + } + } + }, + "system_config.CreateSystemConfigRequest": { + "type": "object", + "required": [ + "key", + "value" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255 + }, + "key": { + "type": "string", + "maxLength": 64 + }, + "value": { + "type": "string", + "maxLength": 255 + } + } + }, + "system_config.UpdateSystemConfigRequest": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255 + }, + "value": { + "type": "string", + "maxLength": 255 + } + } + }, + "task.DispatchTaskRequest": { + "type": "object", + "required": [ + "task_type" + ], + "properties": { + "end_time": { + "type": "string" + }, + "start_time": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "user.UpdatePayKeyRequest": { + "type": "object", + "required": [ + "pay_key" + ], + "properties": { + "pay_key": { + "type": "string", + "maxLength": 6 + } + } + }, + "user.updateUserStatusRequest": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + } + } + }, + "user_pay_config.CreateUserPayConfigRequest": { + "type": "object", + "required": [ + "distribute_rate", + "fee_rate", + "score_rate" + ], + "properties": { + "daily_limit": { + "type": "integer" + }, + "distribute_rate": { + "type": "number" + }, + "fee_rate": { + "type": "number" + }, + "level": { + "$ref": "#/definitions/model.PayLevel" + }, + "max_score": { + "type": "integer" + }, + "min_score": { + "type": "integer" + }, + "score_rate": { + "type": "number" + } + } + }, + "user_pay_config.UpdateUserPayConfigRequest": { + "type": "object", + "required": [ + "distribute_rate", + "fee_rate", + "score_rate" + ], + "properties": { + "daily_limit": { + "type": "integer" + }, + "distribute_rate": { + "type": "number" + }, + "fee_rate": { + "type": "number" + }, + "max_score": { + "type": "integer" + }, + "min_score": { + "type": "integer" + }, + "score_rate": { + "type": "number" + } + } + }, + "util.ResponseAny": { + "type": "object", + "properties": { + "data": {}, + "error_msg": { + "type": "string", + "example": "" + } + } + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 00000000..d6268ff4 --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,1658 @@ +definitions: + api_key.CreateAPIKeyRequest: + properties: + app_description: + maxLength: 100 + type: string + app_homepage_url: + maxLength: 100 + type: string + app_name: + maxLength: 20 + type: string + notify_url: + maxLength: 100 + type: string + public_key: + maxLength: 100 + type: string + redirect_uri: + maxLength: 100 + type: string + test_mode: + type: boolean + required: + - app_homepage_url + - app_name + - notify_url + type: object + api_key.UpdateAPIKeyRequest: + properties: + app_description: + maxLength: 100 + type: string + app_homepage_url: + maxLength: 100 + type: string + app_name: + maxLength: 20 + type: string + notify_url: + maxLength: 100 + type: string + public_key: + maxLength: 100 + type: string + redirect_uri: + maxLength: 100 + type: string + test_mode: + type: boolean + type: object + dashboard.UserBalanceStatsResponse: + properties: + avg_amount: + type: number + max_amount: + type: number + median_amount: + type: number + min_amount: + type: number + std_dev: + type: number + total_amount: + type: number + total_count: + type: integer + type: object + dispute.CloseDisputeRequest: + properties: + dispute_id: + example: "0" + type: string + required: + - dispute_id + type: object + dispute.CreateDisputeRequest: + properties: + order_id: + example: "0" + type: string + reason: + maxLength: 100 + type: string + required: + - order_id + - reason + type: object + dispute.ListDisputesRequest: + properties: + dispute_id: + example: "0" + type: string + page: + minimum: 1 + type: integer + page_size: + maximum: 100 + minimum: 1 + type: integer + status: + enum: + - disputing + - refund + - closed + type: string + type: object + dispute.RefundReviewRequest: + properties: + dispute_id: + example: "0" + type: string + reason: + maxLength: 100 + type: string + status: + enum: + - refund + - closed + type: string + required: + - dispute_id + - status + type: object + link.PayByLinkRequest: + properties: + pay_key: + maxLength: 6 + type: string + remark: + maxLength: 100 + type: string + token: + type: string + required: + - pay_key + - token + type: object + link.PaymentLinkRequest: + properties: + amount: + type: number + product_name: + maxLength: 30 + type: string + remark: + maxLength: 100 + type: string + total_limit: + minimum: 1 + type: integer + user_limit: + minimum: 1 + type: integer + required: + - amount + - product_name + type: object + model.OrderTransferStatus: + enum: + - pending + - completed + type: string + x-enum-varnames: + - OrderTransferStatusPending + - OrderTransferStatusCompleted + model.PayLevel: + enum: + - 0 + - 1 + - 2 + - 3 + format: int32 + type: integer + x-enum-varnames: + - PayLevelFree + - PayLevelBasic + - PayLevelStandard + - PayLevelPremium + model.RedEnvelopeType: + enum: + - fixed + - random + type: string + x-enum-varnames: + - RedEnvelopeTypeFixed + - RedEnvelopeTypeRandom + oauth.CallbackRequest: + properties: + code: + type: string + state: + type: string + type: object + order.TransactionListRequest: + properties: + client_id: + type: string + endTime: + type: string + id: + example: "0" + type: string + order_name: + type: string + page: + minimum: 1 + type: integer + page_size: + maximum: 100 + minimum: 1 + type: integer + payee_transfer_status: + allOf: + - $ref: '#/definitions/model.OrderTransferStatus' + enum: + - pending + - completed + payee_username: + type: string + payer_username: + type: string + startTime: + type: string + statuses: + items: + type: string + type: array + types: + items: + type: string + type: array + type: object + payment.CreateOrderRequest: + properties: + amount: + type: number + merchant_order_no: + maxLength: 64 + minLength: 1 + type: string + notify_url: + maxLength: 100 + type: string + order_name: + maxLength: 64 + type: string + payment_type: + type: string + remark: + maxLength: 100 + type: string + return_url: + maxLength: 100 + type: string + required: + - amount + - order_name + type: object + payment.MerchantDistributeRequest: + properties: + amount: + type: number + out_trade_no: + maxLength: 64 + minLength: 1 + type: string + remark: + maxLength: 100 + type: string + user_id: + type: integer + username: + type: string + required: + - amount + - user_id + - username + type: object + payment.PayOrderRequest: + properties: + order_no: + type: string + pay_key: + maxLength: 6 + type: string + required: + - order_no + - pay_key + type: object + payment.QueryMerchantOrderResponse: + properties: + addtime: + example: "2023-12-08 12:00:00" + type: string + code: + example: 1 + type: integer + endtime: + example: "2023-12-08 12:05:00" + type: string + money: + example: "10.00" + type: string + msg: + example: 查询订单号成功! + type: string + name: + example: 商品名称 + type: string + out_trade_no: + example: M202312080001 + type: string + pid: + example: "1001" + type: string + status: + example: 1 + type: integer + trade_no: + example: "123456" + type: string + type: + example: epay + type: string + type: object + payment.RefundMerchantOrderResponse: + properties: + code: + example: 1 + type: integer + msg: + example: 退款成功 + type: string + type: object + payment.RefundOrderRequest: + properties: + key: + type: string + money: + type: number + out_trade_no: + type: string + pid: + type: string + trade_no: + type: integer + required: + - key + - money + - pid + - trade_no + type: object + payment.TransferRequest: + properties: + amount: + type: number + pay_key: + maxLength: 6 + type: string + recipient_id: + example: "0" + type: string + recipient_username: + type: string + remark: + maxLength: 100 + type: string + required: + - amount + - pay_key + - recipient_id + - recipient_username + type: object + redenvelope.ClaimRequest: + properties: + id: + example: "0" + type: string + required: + - id + type: object + redenvelope.CreateRequest: + properties: + cover_upload_id: + example: "0" + type: string + greeting: + maxLength: 100 + type: string + heterotypic_upload_id: + example: "0" + type: string + pay_key: + maxLength: 10 + type: string + total_amount: + type: number + total_count: + minimum: 1 + type: integer + type: + allOf: + - $ref: '#/definitions/model.RedEnvelopeType' + enum: + - fixed + - random + required: + - pay_key + - total_amount + - total_count + - type + type: object + redenvelope.ListRequest: + properties: + page: + minimum: 1 + type: integer + page_size: + maximum: 100 + minimum: 1 + type: integer + type: + enum: + - sent + - received + type: string + required: + - page + - page_size + type: object + system_config.CreateSystemConfigRequest: + properties: + description: + maxLength: 255 + type: string + key: + maxLength: 64 + type: string + value: + maxLength: 255 + type: string + required: + - key + - value + type: object + system_config.UpdateSystemConfigRequest: + properties: + description: + maxLength: 255 + type: string + value: + maxLength: 255 + type: string + required: + - value + type: object + task.DispatchTaskRequest: + properties: + end_time: + type: string + start_time: + type: string + task_type: + type: string + user_id: + type: integer + required: + - task_type + type: object + user.UpdatePayKeyRequest: + properties: + pay_key: + maxLength: 6 + type: string + required: + - pay_key + type: object + user.updateUserStatusRequest: + properties: + is_active: + type: boolean + type: object + user_pay_config.CreateUserPayConfigRequest: + properties: + daily_limit: + type: integer + distribute_rate: + type: number + fee_rate: + type: number + level: + $ref: '#/definitions/model.PayLevel' + max_score: + type: integer + min_score: + type: integer + score_rate: + type: number + required: + - distribute_rate + - fee_rate + - score_rate + type: object + user_pay_config.UpdateUserPayConfigRequest: + properties: + daily_limit: + type: integer + distribute_rate: + type: number + fee_rate: + type: number + max_score: + type: integer + min_score: + type: integer + score_rate: + type: number + required: + - distribute_rate + - fee_rate + - score_rate + type: object + util.ResponseAny: + properties: + data: {} + error_msg: + example: "" + type: string + type: object +info: + contact: {} + title: LINUX DO Credit + version: 1.0.0 +paths: + /api.php: + get: + consumes: + - application/json + parameters: + - in: query + name: act + type: string + - in: query + name: key + required: true + type: string + - in: query + maxLength: 64 + minLength: 1 + name: out_trade_no + required: true + type: string + - in: query + name: pid + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/payment.QueryMerchantOrderResponse' + tags: + - payment + post: + consumes: + - application/json + parameters: + - description: 退款请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/payment.RefundOrderRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/payment.RefundMerchantOrderResponse' + tags: + - payment + /api/v1/admin/system-configs: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + post: + consumes: + - application/json + parameters: + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/system_config.CreateSystemConfigRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + /api/v1/admin/system-configs/{key}: + delete: + parameters: + - description: 配置键 + in: path + name: key + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + get: + parameters: + - description: 配置键 + in: path + name: key + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + put: + consumes: + - application/json + parameters: + - description: 配置键 + in: path + name: key + required: true + type: string + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/system_config.UpdateSystemConfigRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + /api/v1/admin/tasks/dispatch: + post: + consumes: + - application/json + parameters: + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/task.DispatchTaskRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + /api/v1/admin/tasks/types: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + /api/v1/admin/user-pay-configs: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + post: + consumes: + - application/json + parameters: + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/user_pay_config.CreateUserPayConfigRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + /api/v1/admin/user-pay-configs/{id}: + delete: + parameters: + - description: 配置ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + get: + parameters: + - description: 配置ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + put: + consumes: + - application/json + parameters: + - description: 配置ID + in: path + name: id + required: true + type: string + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/user_pay_config.UpdateUserPayConfigRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + /api/v1/admin/users: + get: + parameters: + - in: query + minimum: 1 + name: page + type: integer + - in: query + maximum: 100 + minimum: 1 + name: page_size + type: integer + - in: query + name: user_id + type: integer + - in: query + name: username + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + /api/v1/admin/users/{id}/status: + put: + parameters: + - description: 用户ID + in: path + name: id + required: true + type: integer + - description: 状态 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.updateUserStatusRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - admin + /api/v1/config/public: + get: + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - config + /api/v1/dashboard/stats/daily: + get: + consumes: + - application/json + parameters: + - description: 查询天数,最大7天 + in: query + name: days + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + summary: 获取每日收支统计 + tags: + - dashboard + /api/v1/dashboard/stats/top-customers: + get: + consumes: + - application/json + parameters: + - description: 查询天数,最大7天 + in: query + name: days + required: true + type: integer + - description: 返回数量,最大10 + in: query + name: limit + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + summary: 获取Top客户 + tags: + - dashboard + /api/v1/dashboard/stats/user-balance: + get: + consumes: + - application/json + description: 统计所有用户的AvailableBalance字段 + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/dashboard.UserBalanceStatsResponse' + type: object + summary: 获取用户余额统计 + tags: + - dashboard + /api/v1/health: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - health + /api/v1/leaderboard: + get: + consumes: + - application/json + parameters: + - description: 页码 + in: query + name: page + required: true + type: integer + - description: 每页数量 + in: query + name: page_size + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + summary: 获取排行榜列表 + tags: + - leaderboard + /api/v1/leaderboard/me: + get: + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + summary: 获取当前用户排名 + tags: + - leaderboard + /api/v1/leaderboard/users/{id}: + get: + consumes: + - application/json + parameters: + - description: 用户ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + summary: 获取指定用户排名 + tags: + - leaderboard + /api/v1/merchant/api-keys: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - merchant + post: + consumes: + - application/json + parameters: + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/api_key.CreateAPIKeyRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - merchant + /api/v1/merchant/api-keys/{id}: + delete: + parameters: + - description: API Key ID + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - merchant + get: + parameters: + - description: API Key ID + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - merchant + put: + consumes: + - application/json + parameters: + - description: API Key ID + format: int64 + in: path + name: id + required: true + type: integer + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/api_key.UpdateAPIKeyRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - merchant + /api/v1/merchant/api-keys/{id}/payment-links: + get: + parameters: + - description: API Key ID + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - merchant + post: + consumes: + - application/json + parameters: + - description: API Key ID + format: int64 + in: path + name: id + required: true + type: integer + - description: 创建支付链接请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/link.PaymentLinkRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - merchant + /api/v1/merchant/api-keys/{id}/payment-links/{linkId}: + delete: + parameters: + - description: API Key ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Payment Link ID + format: int64 + in: path + name: linkId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - merchant + put: + consumes: + - application/json + parameters: + - description: API Key ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Payment Link ID + format: int64 + in: path + name: linkId + required: true + type: integer + - description: 更新支付链接请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/link.PaymentLinkRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - merchant + /api/v1/merchant/payment: + post: + consumes: + - application/json + parameters: + - description: 支付订单请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/payment.PayOrderRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - payment + /api/v1/merchant/payment-links/{token}: + get: + parameters: + - description: 支付链接 Token + in: path + name: token + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - merchant + /api/v1/merchant/payment-links/pay: + post: + consumes: + - application/json + parameters: + - description: 支付请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/link.PayByLinkRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - merchant + /api/v1/merchant/payment/order: + get: + consumes: + - application/json + parameters: + - description: 订单号 + in: query + name: order_no + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - payment + /api/v1/oauth/callback: + post: + parameters: + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/oauth.CallbackRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - oauth + /api/v1/oauth/login: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - oauth + /api/v1/oauth/logout: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - oauth + /api/v1/oauth/user-info: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - oauth + /api/v1/order/dispute: + post: + consumes: + - application/json + parameters: + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/dispute.CreateDisputeRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - order + /api/v1/order/dispute/close: + post: + consumes: + - application/json + parameters: + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/dispute.CloseDisputeRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - order + /api/v1/order/disputes: + post: + consumes: + - application/json + parameters: + - description: request body + in: body + name: request + schema: + $ref: '#/definitions/dispute.ListDisputesRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - order + /api/v1/order/disputes/merchant: + post: + consumes: + - application/json + parameters: + - description: request body + in: body + name: request + schema: + $ref: '#/definitions/dispute.ListDisputesRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - order + /api/v1/order/refund-review: + post: + consumes: + - application/json + parameters: + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/dispute.RefundReviewRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - order + /api/v1/order/transactions: + post: + consumes: + - application/json + parameters: + - description: request body + in: body + name: request + schema: + $ref: '#/definitions/order.TransactionListRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - order + /api/v1/payment/transfer: + post: + consumes: + - application/json + parameters: + - description: 转账请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/payment.TransferRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - payment + /api/v1/redenvelope/{id}: + get: + parameters: + - description: 红包ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - redenvelope + /api/v1/redenvelope/claim: + post: + consumes: + - application/json + parameters: + - description: 领取红包请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/redenvelope.ClaimRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - redenvelope + /api/v1/redenvelope/covers: + get: + parameters: + - description: 封面类型 (cover/heterotypic) + in: query + name: type + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - redenvelope + /api/v1/redenvelope/create: + post: + consumes: + - application/json + parameters: + - description: 创建红包请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/redenvelope.CreateRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - redenvelope + /api/v1/redenvelope/list: + post: + consumes: + - application/json + parameters: + - description: 列表请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/redenvelope.ListRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - redenvelope + /api/v1/upload/redenvelope/cover: + post: + consumes: + - multipart/form-data + parameters: + - description: 图片文件 + in: formData + name: file + required: true + type: file + - description: 封面类型 (cover/heterotypic) + in: formData + name: type + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - upload + /api/v1/user/pay-key: + put: + consumes: + - application/json + parameters: + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.UpdatePayKeyRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - user + /f/{id}: + get: + parameters: + - description: Upload ID + in: path + name: id + required: true + type: string + produces: + - application/octet-stream + responses: + "200": + description: OK + tags: + - upload + /pay/distribute: + post: + consumes: + - application/json + parameters: + - description: Basic Auth (base64(client_id:client_secret)) + in: header + name: Authorization + required: true + type: string + - description: 分发请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/payment.MerchantDistributeRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - payment + /pay/submit.php: + get: + consumes: + - application/x-www-form-urlencoded + parameters: + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/payment.CreateOrderRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - payment + post: + consumes: + - application/x-www-form-urlencoded + parameters: + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/payment.CreateOrderRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - payment +swagger: "2.0" diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..a855cd4f --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,11 @@ +# 前端 URL +LINUX_DO_CREDIT_FRONTEND_URL=http://localhost:3000 + +# 后端 URL +LINUX_DO_CREDIT_BACKEND_URL=http://localhost:8000 + +# Session Cookie +LINUX_DO_CREDIT_SESSION_COOKIE_NAME=linux_do_credit_session_id + +# 环境 +NODE_ENV=production \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..a411c7b6 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,52 @@ +# ---- base stage ---- +FROM node:22-alpine AS base + +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" + +RUN corepack enable && corepack prepare pnpm@10.10.0 --activate + +# ---- build stage ---- +FROM base AS builder + +ENV NODE_ENV=production +ENV NEXT_PUBLIC_LINUX_DO_CREDIT_BACKEND_URL=https://build-placeholder.invalid +ENV LINUX_DO_CREDIT_SESSION_COOKIE_NAME=__LINUX_DO_CREDIT_SESSION_COOKIE_NAME__ +ENV LINUX_DO_CREDIT_RATE_LIMIT_ENABLED=__LINUX_DO_CREDIT_RATE_LIMIT_ENABLED__ +ENV LINUX_DO_CREDIT_BACKEND_URL=https://build-placeholder-2.invalid + +ARG VERSION="" +ARG BUILD_DATE="" + +WORKDIR /app + +COPY package.json pnpm-lock.yaml ./ + +RUN pnpm install --frozen-lockfile + +COPY . . + +RUN if [ -n "$VERSION" ] || [ -n "$BUILD_DATE" ]; then \ + VERSION="$VERSION" BUILD_DATE="$BUILD_DATE" node -e "const fs = require('fs'); const pkg = require('./package.json'); const version = process.env.VERSION; const buildDate = process.env.BUILD_DATE; if (version) pkg.version = version; if (buildDate) pkg.buildDate = buildDate; fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\\n')"; \ +fi + +RUN pnpm build + +RUN grep -rl \ + -e "https://build-placeholder.invalid" \ + -e "https://build-placeholder-2.invalid" \ + -e "__LINUX_DO_CREDIT_SESSION_COOKIE_NAME__" \ + -e "__LINUX_DO_CREDIT_RATE_LIMIT_ENABLED__" \ + /app/.next > /app/.replace.files + +# ---- runner stage ---- +FROM base AS runner + +WORKDIR /app + +COPY --from=builder /app . + +EXPOSE 3000 + +ENTRYPOINT ["./entrypoint.sh"] +CMD ["pnpm", "start"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..412a6b4b --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,50 @@ +# Linux Do Credit Frontend + +Modern frontend application for the Linux Do Credit system. + +[中文](./README_zh.md) | English + +## Tech Stack + +- **Framework**: [Next.js 16](https://nextjs.org/) +- **Styling**: [Tailwind CSS 4](https://tailwindcss.com/) +- **UI Components**: [Radix UI](https://www.radix-ui.com/) +- **Icons**: [Lucide React](https://lucide.dev/) +- **Package Manager**: [pnpm](https://pnpm.io/) + +## Getting Started + +### Prerequisites + +- Node.js (Latest LTS recommended) +- pnpm + +### Installation + +1. Install dependencies: + ```bash + pnpm install + ``` + +2. Run the development server: + ```bash + pnpm dev + ``` + + Open [http://localhost:3000](http://localhost:3000) (or the port shown in your terminal) with your browser to see the result. + +## Project Structure + +- `app/`: Next.js App Router pages and layouts +- `components/`: Reusable UI components + - `ui/`: Base UI components (buttons, inputs, etc.) + - `common/`: Shared business components +- `lib/`: Utility functions and service definitions +- `public/`: Static assets + +## Scripts + +- `pnpm dev`: Start development server with Turbopack +- `pnpm build`: Build the application for production +- `pnpm start`: Start production server +- `pnpm lint`: Run ESLint diff --git a/frontend/README_zh.md b/frontend/README_zh.md new file mode 100644 index 00000000..33b0b12c --- /dev/null +++ b/frontend/README_zh.md @@ -0,0 +1,50 @@ +# Linux Do Credit Frontend + +Linux Do Credit 系统的现代化前端应用。 + +[English](./README.md) | 中文 + +## 技术栈 + +- **框架**: [Next.js 16](https://nextjs.org/) +- **样式**: [Tailwind CSS 4](https://tailwindcss.com/) +- **UI 组件**: [Radix UI](https://www.radix-ui.com/) +- **图标**: [Lucide React](https://lucide.dev/) +- **包管理器**: [pnpm](https://pnpm.io/) + +## 快速开始 + +### 前置要求 + +- Node.js (建议使用最新的 LTS 版本) +- pnpm + +### 安装 + +1. 安装依赖: + ```bash + pnpm install + ``` + +2. 运行开发服务器: + ```bash + pnpm dev + ``` + + 在浏览器中打开 [http://localhost:3000](http://localhost:3000) (或终端中显示的端口) 查看结果。 + +## 项目结构 + +- `app/`: Next.js App Router 页面和布局 +- `components/`: 可复用 UI 组件 + - `ui/`: 基础 UI 组件 (按钮, 输入框等) + - `common/`: 共享业务组件 +- `lib/`: 工具函数和服务定义 +- `public/`: 静态资源 + +## 脚本 + +- `pnpm dev`: 启动开发服务器 (使用 Turbopack) +- `pnpm build`: 构建生产版本 +- `pnpm start`: 启动生产服务器 +- `pnpm lint`: 运行 ESLint diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx new file mode 100644 index 00000000..5c3f1f52 --- /dev/null +++ b/frontend/app/(auth)/login/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react" +import { LoginPage } from "@/components/auth/login-page" + +export default function Page() { + return ( + + + + ) +} diff --git a/frontend/app/(docs)/docs/api/page.tsx b/frontend/app/(docs)/docs/api/page.tsx new file mode 100644 index 00000000..8ba38086 --- /dev/null +++ b/frontend/app/(docs)/docs/api/page.tsx @@ -0,0 +1,18 @@ + +import { LegalPageLayout } from "@/components/common/docs/legal-page-layout"; +import { apiSections, DOCS_LAST_UPDATED } from "@/components/common/docs/api"; + +export default function ApiDocPage() { + return ( + + LINUX DO Credit 提供简单、强大的 API 接口,支持多种编程语言和开发环境。通过标准化的 HTTP 接口,您可以轻松将积分服务集成到您的应用中。 +

+ } + /> + ); +} diff --git a/frontend/app/(docs)/docs/how-to-use/page.tsx b/frontend/app/(docs)/docs/how-to-use/page.tsx new file mode 100644 index 00000000..0724d257 --- /dev/null +++ b/frontend/app/(docs)/docs/how-to-use/page.tsx @@ -0,0 +1,19 @@ + +import { LegalPageLayout } from "@/components/common/docs/legal-page-layout"; +import { howToUseSections } from "@/components/common/docs/how-to-use"; +import { DOCS_LAST_UPDATED } from "@/components/common/docs/api"; + +export default function HowToUsePage() { + return ( + + 欢迎使用 LINUX DO Credit。本文档将引导您快速了解平台的核心功能、角色定义及使用流程,帮助您更好地使用我们的服务。 +

+ } + /> + ); +} diff --git a/frontend/app/(docs)/docs/privacy-policy/page.tsx b/frontend/app/(docs)/docs/privacy-policy/page.tsx new file mode 100644 index 00000000..50902494 --- /dev/null +++ b/frontend/app/(docs)/docs/privacy-policy/page.tsx @@ -0,0 +1,20 @@ +import { LegalPageLayout } from "@/components/common/docs/legal-page-layout" +import { privacySections } from "@/components/common/docs/privacy" +import { TERMS_LAST_UPDATED } from "@/components/common/docs/terms" + +export default function PrivacyPolicyPage() { + return ( + + 我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息, +
+ 以及您享有的相关权利。 + + } + /> + ) +} diff --git a/frontend/app/(docs)/docs/terms-of-service/page.tsx b/frontend/app/(docs)/docs/terms-of-service/page.tsx new file mode 100644 index 00000000..8725147d --- /dev/null +++ b/frontend/app/(docs)/docs/terms-of-service/page.tsx @@ -0,0 +1,19 @@ +import { LegalPageLayout } from "@/components/common/docs/legal-page-layout" +import { termsSections, TERMS_LAST_UPDATED } from "@/components/common/docs/terms" + +export default function TermsOfServicePage() { + return ( + + 欢迎使用 LINUX DO Credit。本协议详述了您在使用本平台服务时的权利与义务。 +
+ 为了保障您的合法权益,请您仔细阅读以下条款。 + + } + /> + ) +} diff --git a/frontend/app/(main)/admin/credit/page.tsx b/frontend/app/(main)/admin/credit/page.tsx new file mode 100644 index 00000000..20b4075d --- /dev/null +++ b/frontend/app/(main)/admin/credit/page.tsx @@ -0,0 +1,29 @@ +"use client" + +import { useEffect } from "react" +import { UserPayConfigs } from "@/components/common/admin/credit" +import { useUser } from "@/contexts/user-context" +import { AdminProvider, useAdmin } from "@/contexts/admin-context" + +/* 用户积分配置页面 */ +export default function UserPayConfigPage() { + return ( + + + + ) +} + +/* 用户积分配置页面内容 */ +function UserPayConfigPageContent() { + const { user } = useUser() + const { refetchUserPayConfigs } = useAdmin() + + useEffect(() => { + if (user?.is_admin) { + refetchUserPayConfigs() + } + }, [user?.is_admin, refetchUserPayConfigs]) + + return +} diff --git a/frontend/app/(main)/admin/layout.tsx b/frontend/app/(main)/admin/layout.tsx new file mode 100644 index 00000000..2cc9a5f4 --- /dev/null +++ b/frontend/app/(main)/admin/layout.tsx @@ -0,0 +1,35 @@ +"use client" + +import { AdminUsersProvider } from "@/contexts/admin-users-context" +import { useUser } from "@/contexts/user-context" +import { LoadingPage } from "@/components/layout/loading" +import { ErrorPage } from "@/components/layout/error" + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode +}) { + const { user, loading } = useUser() + + /* 等待用户信息加载完成 */ + if (loading) { + return + } + + /* 权限检查:只有管理员才能访问 */ + if (!user?.is_admin) { + return ( + + ) + } + + return ( + + {children} + + ) +} diff --git a/frontend/app/(main)/admin/system/page.tsx b/frontend/app/(main)/admin/system/page.tsx new file mode 100644 index 00000000..93c1690e --- /dev/null +++ b/frontend/app/(main)/admin/system/page.tsx @@ -0,0 +1,30 @@ +"use client" + +import { useEffect } from "react" +import { SystemConfigs } from "@/components/common/admin/system" +import { useUser } from "@/contexts/user-context" +import { AdminProvider, useAdmin } from "@/contexts/admin-context" + +/* 系统配置页面 */ +export default function SystemConfigPage() { + return ( + + + + ) +} + +/* 系统配置页面内容 */ +function SystemConfigPageContent() { + const { user } = useUser() + const { refetchSystemConfigs } = useAdmin() + + useEffect(() => { + if (user?.is_admin) { + refetchSystemConfigs() + } + }, [user?.is_admin, refetchSystemConfigs]) + + return +} + diff --git a/frontend/app/(main)/admin/tasks/page.tsx b/frontend/app/(main)/admin/tasks/page.tsx new file mode 100644 index 00000000..39e329a5 --- /dev/null +++ b/frontend/app/(main)/admin/tasks/page.tsx @@ -0,0 +1,8 @@ +"use client" + +import { TaskManager } from "@/components/common/admin/tasks" + +/* 任务管理页面 */ +export default function TasksPage() { + return +} diff --git a/frontend/app/(main)/admin/users/page.tsx b/frontend/app/(main)/admin/users/page.tsx new file mode 100644 index 00000000..6e20d868 --- /dev/null +++ b/frontend/app/(main)/admin/users/page.tsx @@ -0,0 +1,8 @@ +"use client" + +import { UsersManager } from "@/components/common/admin/users" + +/* 用户管理页面 */ +export default function UsersPage() { + return +} diff --git a/frontend/app/(main)/balance/page.tsx b/frontend/app/(main)/balance/page.tsx new file mode 100644 index 00000000..6c192d70 --- /dev/null +++ b/frontend/app/(main)/balance/page.tsx @@ -0,0 +1,5 @@ +import { BalanceMain } from "@/components/common/balance/balance-main" + +export default function BalancePage() { + return +} diff --git a/frontend/app/(main)/home/page.tsx b/frontend/app/(main)/home/page.tsx new file mode 100644 index 00000000..14f06b5b --- /dev/null +++ b/frontend/app/(main)/home/page.tsx @@ -0,0 +1,5 @@ +import { HomeMain } from "@/components/common/home/home-main" + +export default function HomePage() { + return +} diff --git a/frontend/app/(main)/layout.tsx b/frontend/app/(main)/layout.tsx new file mode 100644 index 00000000..d8361612 --- /dev/null +++ b/frontend/app/(main)/layout.tsx @@ -0,0 +1,57 @@ +"use client" + +import { motion } from "motion/react" +import { usePathname } from "next/navigation" +import { useState } from "react" +import { AppSidebar } from "@/components/layout/sidebar" +import { SiteHeader } from "@/components/layout/header" +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" + +import { UserProvider } from "@/contexts/user-context" +import { MerchantProvider } from "@/contexts/merchant-context" + + +export default function MainLayout({ + children, +}: { + children: React.ReactNode +}) { + const pathname = usePathname() + const [isFullWidth, setIsFullWidth] = useState(false) + + return ( + + + + + + +
+
+ + {children} + +
+
+
+
+
+
+ ) +} diff --git a/frontend/app/(main)/leaderboard/page.tsx b/frontend/app/(main)/leaderboard/page.tsx new file mode 100644 index 00000000..085d0ba2 --- /dev/null +++ b/frontend/app/(main)/leaderboard/page.tsx @@ -0,0 +1,5 @@ +import { LeaderboardMain } from "@/components/common/leaderboard/leaderboard-main" + +export default function LeaderboardPage() { + return +} diff --git a/frontend/app/(main)/merchant/online-paying/page.tsx b/frontend/app/(main)/merchant/online-paying/page.tsx new file mode 100644 index 00000000..24b06827 --- /dev/null +++ b/frontend/app/(main)/merchant/online-paying/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react" +import { MerchantOnline } from "@/components/common/merchant/merchant-online" + +export default function Page() { + return ( + + + + ) +} diff --git a/frontend/app/(main)/merchant/page.tsx b/frontend/app/(main)/merchant/page.tsx new file mode 100644 index 00000000..10a64b02 --- /dev/null +++ b/frontend/app/(main)/merchant/page.tsx @@ -0,0 +1,5 @@ +import { MerchantMain } from "@/components/common/merchant/merchant-main" + +export default function MerchantPage() { + return +} diff --git a/frontend/app/(main)/settings/appearance/page.tsx b/frontend/app/(main)/settings/appearance/page.tsx new file mode 100644 index 00000000..93d2d3b4 --- /dev/null +++ b/frontend/app/(main)/settings/appearance/page.tsx @@ -0,0 +1,5 @@ +import { AppearanceMain } from "@/components/common/settings/appearance" + +export default function AppearancePage() { + return +} diff --git a/frontend/app/(main)/settings/notifications/page.tsx b/frontend/app/(main)/settings/notifications/page.tsx new file mode 100644 index 00000000..f074147d --- /dev/null +++ b/frontend/app/(main)/settings/notifications/page.tsx @@ -0,0 +1,5 @@ +import { NotificationsMain } from "@/components/common/settings/notifications" + +export default function NotificationsPage() { + return +} diff --git a/frontend/app/(main)/settings/page.tsx b/frontend/app/(main)/settings/page.tsx new file mode 100644 index 00000000..4802d9d1 --- /dev/null +++ b/frontend/app/(main)/settings/page.tsx @@ -0,0 +1,77 @@ +import Link from "next/link" +import { Card, CardTitle, CardDescription, CardContent } from "@/components/ui/card" +import { UserRound, Bell, Shield, Palette } from "lucide-react" + + +/* 设置项 */ +const settingsItems = [ + { + title: "个人资料", + description: "查看您的账户信息", + icon: UserRound, + href: "/settings/profile", + category: "个人设置", + }, + { + title: "通知设置", + description: "设置您的通知偏好", + icon: Bell, + href: "/settings/notifications", + category: "账户设置", + }, + { + title: "安全设置", + description: "管理您的账户安全", + icon: Shield, + href: "/settings/security", + category: "账户设置", + }, + { + title: "外观设置", + description: "自定义界面主题和显示", + icon: Palette, + href: "/settings/appearance", + category: "个人设置", + }, +] + +export default function SettingsPage() { + const groupedSettings = settingsItems.reduce((acc, item) => { + if (!acc[item.category]) { + acc[item.category] = [] + } + acc[item.category].push(item) + return acc + }, {} as Record) + + return ( +
+
设置
+ + {Object.entries(groupedSettings).map(([category, items]) => ( +
+
{category}
+
+ {items.map((item) => ( + + + +
+ +
+ {item.title} + + {item.description} + +
+
+
+
+ + ))} +
+
+ ))} +
+ ) +} diff --git a/frontend/app/(main)/settings/profile/page.tsx b/frontend/app/(main)/settings/profile/page.tsx new file mode 100644 index 00000000..91ba1503 --- /dev/null +++ b/frontend/app/(main)/settings/profile/page.tsx @@ -0,0 +1,5 @@ +import { ProfileMain } from "@/components/common/settings/profile" + +export default function ProfilePage() { + return +} diff --git a/frontend/app/(main)/settings/security/page.tsx b/frontend/app/(main)/settings/security/page.tsx new file mode 100644 index 00000000..7df792b8 --- /dev/null +++ b/frontend/app/(main)/settings/security/page.tsx @@ -0,0 +1,5 @@ +import { SecurityMain } from "@/components/common/settings/security" + +export default function SecurityPage() { + return +} diff --git a/frontend/app/(main)/trade/page.tsx b/frontend/app/(main)/trade/page.tsx new file mode 100644 index 00000000..5c37cad7 --- /dev/null +++ b/frontend/app/(main)/trade/page.tsx @@ -0,0 +1,5 @@ +import { TradeMain } from "@/components/common/trade/trade-main" + +export default function TradePage() { + return +} diff --git a/frontend/app/(pay)/layout.tsx b/frontend/app/(pay)/layout.tsx new file mode 100644 index 00000000..f3688ffd --- /dev/null +++ b/frontend/app/(pay)/layout.tsx @@ -0,0 +1,22 @@ +"use client" + +import { MerchantProvider } from "@/contexts/merchant-context" +import { UserProvider } from "@/contexts/user-context" + +/** + * 积分服务页面布局 + * 为积分服务相关的页面提供社区用户和开发者上下文 + */ +export default function PayLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + {children} + + + ) +} diff --git a/frontend/app/(pay)/paying/online/page.tsx b/frontend/app/(pay)/paying/online/page.tsx new file mode 100644 index 00000000..562514e3 --- /dev/null +++ b/frontend/app/(pay)/paying/online/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react" +import { PayingOnline } from "@/components/common/pay/paying/paying-online" + +export default function Page() { + return ( + + + + ) +} diff --git a/frontend/app/(pay)/paying/page.tsx b/frontend/app/(pay)/paying/page.tsx new file mode 100644 index 00000000..1a8cfd06 --- /dev/null +++ b/frontend/app/(pay)/paying/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react" +import { PayingMain } from "@/components/common/pay/paying/paying-main" + +export default function Page() { + return ( + + + + ) +} diff --git a/frontend/app/(redenvelope)/layout.tsx b/frontend/app/(redenvelope)/layout.tsx new file mode 100644 index 00000000..9ead5ac7 --- /dev/null +++ b/frontend/app/(redenvelope)/layout.tsx @@ -0,0 +1,16 @@ +/** + * 红包页面布局 + * 提供简洁的布局,不包含侧边栏和用户上下文 + * 允许未登录用户访问红包详情 + */ +export default function RedEnvelopeLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+ {children} +
+ ) +} \ No newline at end of file diff --git a/frontend/app/(redenvelope)/redenvelope/[id]/page.tsx b/frontend/app/(redenvelope)/redenvelope/[id]/page.tsx new file mode 100644 index 00000000..6ab5510a --- /dev/null +++ b/frontend/app/(redenvelope)/redenvelope/[id]/page.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from "next" +import { headers } from "next/headers" +import { RedEnvelopeClaimPage } from "@/components/common/redenvelope/red-envelope-claim" + +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params + const requestHeaders = await headers() + const protocol = requestHeaders.get("x-forwarded-proto") ?? "https" + const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "" + const baseUrl = host ? `${protocol}://${host}` : "" + const title = "你收到一个LDC红包" + const description = "点击打开领取" + const url = baseUrl ? `${baseUrl}/redenvelope/${id}` : undefined + const image = baseUrl ? `${baseUrl}/red-envelope.svg` : "/red-envelope.svg" + + return { + title, + description, + openGraph: { + title, + description, + url, + type: "website", + images: [{ url: image, width: 384, height: 512, alt: "红包" }], + } + } +} + +export default async function RedEnvelopePage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + return +} \ No newline at end of file diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico new file mode 100644 index 00000000..df85a1c1 Binary files /dev/null and b/frontend/app/favicon.ico differ diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 00000000..33d51423 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,196 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@plugin "@tailwindcss/typography"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont, var(--font-noto-sans-sc), sans-serif; + --font-mono: var(--font-geist-mono), 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', var(--font-noto-sans-sc), monospace; + --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.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(58.5% 51% 277.117); + --primary-foreground: oklch(98.5% 0% 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --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: hsl(0 0% 98%); + --sidebar-foreground: hsl(240 5.3% 26.1%); + --sidebar-primary: hsl(240 5.9% 10%); + --sidebar-primary-foreground: hsl(0 0% 98%); + --sidebar-accent: hsl(240 4.8% 95.9%); + --sidebar-accent-foreground: hsl(240 5.9% 10%); + --sidebar-border: hsl(220 13% 91%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --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.552 0.016 285.938); + --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: hsl(240 5.9% 10%); + --sidebar-foreground: hsl(240 4.8% 95.9%); + --sidebar-primary: hsl(224.3 76.3% 48%); + --sidebar-primary-foreground: hsl(0 0% 100%); + --sidebar-accent: hsl(240 3.7% 15.9%); + --sidebar-accent-foreground: hsl(240 4.8% 95.9%); + --sidebar-border: hsl(240 3.7% 15.9%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + body { + @apply bg-background text-foreground; + } +} + +.hide-scrollbar::-webkit-scrollbar { + display: none; +} + +.hide-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} + +@theme inline { + /* + * Aurora 动画配置 + */ + --animate-aurora: aurora 120s linear infinite; + + @keyframes aurora { + from { + background-position: + 50% 50%, + 50% 50%; + } + + to { + background-position: + 350% 50%, + 350% 50%; + } + } + + /* + * 红包动画配置 + */ + --animate-redenvelope-shake: redenvelope-shake 0.5s ease-in-out; + --animate-redenvelope-glow: redenvelope-glow 2s ease-in-out infinite; + --animate-gold-particle: gold-particle 1s ease-out forwards; + + @keyframes redenvelope-shake { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(-5deg); } + 75% { transform: rotate(5deg); } + } + + @keyframes redenvelope-glow { + 0%, 100% { box-shadow: 0 0 20px rgba(255, 215, 0, 0.3); } + 50% { box-shadow: 0 0 40px rgba(255, 215, 0, 0.6); } + } + + @keyframes gold-particle { + 0% { transform: translateY(0) scale(1); opacity: 1; } + 100% { transform: translateY(-100px) scale(0); opacity: 0; } + } + + /* + * 铃铛响铃动画配置 + * 使用微妙的摆动效果,优雅且不过度 + */ + --animate-bell-ring: bell-ring 0.6s cubic-bezier(0.36, 0.07, 0.19, 0.97); + + @keyframes bell-ring { + 0% { transform: rotate(0deg); } + 10% { transform: rotate(14deg); } + 20% { transform: rotate(-12deg); } + 30% { transform: rotate(10deg); } + 40% { transform: rotate(-8deg); } + 50% { transform: rotate(6deg); } + 60% { transform: rotate(-4deg); } + 70% { transform: rotate(2deg); } + 80% { transform: rotate(-1deg); } + 100% { transform: rotate(0deg); } + } +} \ No newline at end of file diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 00000000..fb32b7be --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,67 @@ +import type { Metadata } from "next"; +import { Inter, Noto_Sans_SC, Geist_Mono } from 'next/font/google'; +import { Toaster } from "@/components/ui/sonner"; +import { ThemeProvider } from "@/components/layout/theme-provider"; +import { CustomThemeProvider } from "@/lib/theme"; +import { BellRingProvider } from "@/contexts/bell-ring-context"; +import { NotificationSettingsProvider } from "@/contexts/notification-settings-context"; +import { RiskBlockDialog } from "@/components/common/risk/risk-block-dialog"; +import "./globals.css"; + +const inter = Inter({ + variable: '--font-inter', + subsets: ['latin'], + display: 'swap', +}); + +const notoSansSC = Noto_Sans_SC({ + variable: '--font-noto-sans-sc', + subsets: ['latin'], + display: 'swap', + weight: ['300', '400', '500', '600', '700'] +}); + +const geistMono = Geist_Mono({ + variable: '--font-geist-mono', + subsets: ['latin'], +}); + +export const metadata: Metadata = { + title: "LINUX DO Credit", + description: "Linux Do 社区积分服务平台", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + + {children} + + + + + + + + + ); +} diff --git a/frontend/app/not-found.tsx b/frontend/app/not-found.tsx new file mode 100644 index 00000000..a7e99ae5 --- /dev/null +++ b/frontend/app/not-found.tsx @@ -0,0 +1,79 @@ +"use client" + +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { motion } from "motion/react"; +import { Home, ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +/** + * 404 Not Found Page + * 当用户访问不存在的页面时显示 + */ +export default function NotFound() { + const router = useRouter(); + + return ( +
+
+
+
+ +
+ +

+ 404 +

+
+ Page Not Found +
+
+ + +

+ 抱歉,您访问的页面似已迷失在数字星云中。 +
+ 请检查路径或返回安全地带。 +

+ +
+ + + + + +
+
+
+ +
+ ERR_HTTP_NOT_FOUND +
+
+ ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 00000000..1a383e32 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { HeroSection } from "@/components/home/hero-section"; +import { DeveloperSection } from "@/components/home/developer-section"; +import { FooterSection } from "@/components/home/footer-section"; + +export default function HomePage() { + return ( +
+ + + +
+ ) +} diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 00000000..df3be4e7 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@animate-ui": "https://animate-ui.com/r/{name}.json", + "@aceternity": "https://ui.aceternity.com/registry/{name}.json" + } +} diff --git a/frontend/components/animate-ui/components/buttons/button.tsx b/frontend/components/animate-ui/components/buttons/button.tsx new file mode 100644 index 00000000..88529e94 --- /dev/null +++ b/frontend/components/animate-ui/components/buttons/button.tsx @@ -0,0 +1,57 @@ +'use client'; + +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { + Button as ButtonPrimitive, + type ButtonProps as ButtonPrimitiveProps, +} from '@/components/animate-ui/primitives/buttons/button'; +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[box-shadow,_color,_background-color,_border-color,_outline-color,_text-decoration-color,_fill,_stroke] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', + accent: 'bg-accent text-accent-foreground shadow-xs hover:bg-accent/90', + destructive: + 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', + secondary: + 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', + ghost: + 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', + 'icon-sm': 'size-8 rounded-md', + 'icon-lg': 'size-10 rounded-md', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +type ButtonProps = ButtonPrimitiveProps & VariantProps; + +function Button({ className, variant, size, ...props }: ButtonProps) { + return ( + + ); +} + +export { Button, buttonVariants, type ButtonProps }; diff --git a/frontend/components/animate-ui/components/buttons/ripple.tsx b/frontend/components/animate-ui/components/buttons/ripple.tsx new file mode 100644 index 00000000..a1ee0fba --- /dev/null +++ b/frontend/components/animate-ui/components/buttons/ripple.tsx @@ -0,0 +1,56 @@ +'use client'; + +import * as React from 'react'; +import { type VariantProps } from 'class-variance-authority'; + +import { buttonVariants } from '@/components/animate-ui/components/buttons/button'; +import { + RippleButton as RippleButtonPrimitive, + RippleButtonRipples as RippleButtonRipplesPrimitive, + type RippleButtonProps as RippleButtonPrimitiveProps, + type RippleButtonRipplesProps as RippleButtonRipplesPrimitiveProps, +} from '@/components/animate-ui/primitives/buttons/ripple'; +import { cn } from '@/lib/utils'; + +const rippleButtonVariants = { + default: '[--ripple-button-ripple-color:var(--primary-foreground)]', + accent: '[--ripple-button-ripple-color:var(--accent-foreground)]', + destructive: '[--ripple-button-ripple-color:var(--destructive-foreground)]', + outline: '[--ripple-button-ripple-color:var(--foreground)]', + secondary: '[--ripple-button-ripple-color:var(--secondary-foreground)]', + ghost: '[--ripple-button-ripple-color:var(--foreground)]', + link: '[--ripple-button-ripple-color:var(--primary-foreground)]', +}; + +type RippleButtonProps = RippleButtonPrimitiveProps & + VariantProps; + +function RippleButton({ + className, + variant, + size, + ...props +}: RippleButtonProps) { + return ( + + ); +} + +type RippleButtonRipplesProps = RippleButtonRipplesPrimitiveProps; + +function RippleButtonRipples(props: RippleButtonRipplesProps) { + return ; +} + +export { + RippleButton, + RippleButtonRipples, + type RippleButtonProps, + type RippleButtonRipplesProps, +}; diff --git a/frontend/components/animate-ui/icons/check.tsx b/frontend/components/animate-ui/icons/check.tsx new file mode 100644 index 00000000..56deaadc --- /dev/null +++ b/frontend/components/animate-ui/icons/check.tsx @@ -0,0 +1,75 @@ +'use client'; + +import * as React from 'react'; +import { motion, type Variants } from 'motion/react'; + +import { + getVariants, + useAnimateIconContext, + IconWrapper, + type IconProps, +} from '@/components/animate-ui/icons/icon'; + +type CheckProps = IconProps; + +const animations = { + default: { + path: { + initial: { + pathLength: 1, + opacity: 1, + scale: 1, + }, + animate: { + pathLength: [0, 1], + opacity: [0, 1], + scale: [1, 1.1, 1], + transition: { + duration: 0.6, + ease: 'easeInOut', + }, + }, + }, + } satisfies Record, +} as const; + +function IconComponent({ size, ...props }: CheckProps) { + const { controls } = useAnimateIconContext(); + const variants = getVariants(animations); + + return ( + + + + ); +} + +function Check(props: CheckProps) { + return ; +} + +export { + animations, + Check, + Check as CheckIcon, + type CheckProps, + type CheckProps as CheckIconProps, +}; diff --git a/frontend/components/animate-ui/icons/chevron-down.tsx b/frontend/components/animate-ui/icons/chevron-down.tsx new file mode 100644 index 00000000..35cbe7a4 --- /dev/null +++ b/frontend/components/animate-ui/icons/chevron-down.tsx @@ -0,0 +1,78 @@ +'use client'; + +import * as React from 'react'; +import { motion, type Variants } from 'motion/react'; + +import { + getVariants, + useAnimateIconContext, + IconWrapper, + type IconProps, +} from '@/components/animate-ui/icons/icon'; + +type ChevronDownProps = IconProps; + +const animations = { + default: { + path: { + initial: { + y: 0, + transition: { duration: 0.3, ease: 'easeInOut' }, + }, + animate: { + y: 4, + transition: { duration: 0.3, ease: 'easeInOut' }, + }, + }, + } satisfies Record, + 'default-loop': { + path: { + initial: { + y: 0, + }, + animate: { + y: [0, 4, 0], + transition: { duration: 0.6, ease: 'easeInOut' }, + }, + }, + } satisfies Record, +} as const; + +function IconComponent({ size, ...props }: ChevronDownProps) { + const { controls } = useAnimateIconContext(); + const variants = getVariants(animations); + + return ( + + + + ); +} + +function ChevronDown(props: ChevronDownProps) { + return ; +} + +export { + animations, + ChevronDown, + ChevronDown as ChevronDownIcon, + type ChevronDownProps, + type ChevronDownProps as ChevronDownIconProps, +}; diff --git a/frontend/components/animate-ui/icons/chevron-left.tsx b/frontend/components/animate-ui/icons/chevron-left.tsx new file mode 100644 index 00000000..6531bc80 --- /dev/null +++ b/frontend/components/animate-ui/icons/chevron-left.tsx @@ -0,0 +1,78 @@ +'use client'; + +import * as React from 'react'; +import { motion, type Variants } from 'motion/react'; + +import { + getVariants, + useAnimateIconContext, + IconWrapper, + type IconProps, +} from '@/components/animate-ui/icons/icon'; + +type ChevronLeftProps = IconProps; + +const animations = { + default: { + path: { + initial: { + x: 0, + transition: { duration: 0.3, ease: 'easeInOut' }, + }, + animate: { + x: -4, + transition: { duration: 0.3, ease: 'easeInOut' }, + }, + }, + } satisfies Record, + 'default-loop': { + path: { + initial: { + x: 0, + }, + animate: { + x: [0, -4, 0], + transition: { duration: 0.6, ease: 'easeInOut' }, + }, + }, + } satisfies Record, +} as const; + +function IconComponent({ size, ...props }: ChevronLeftProps) { + const { controls } = useAnimateIconContext(); + const variants = getVariants(animations); + + return ( + + + + ); +} + +function ChevronLeft(props: ChevronLeftProps) { + return ; +} + +export { + animations, + ChevronLeft, + ChevronLeft as ChevronLeftIcon, + type ChevronLeftProps, + type ChevronLeftProps as ChevronLeftIconProps, +}; diff --git a/frontend/components/animate-ui/icons/chevron-right.tsx b/frontend/components/animate-ui/icons/chevron-right.tsx new file mode 100644 index 00000000..232df0e6 --- /dev/null +++ b/frontend/components/animate-ui/icons/chevron-right.tsx @@ -0,0 +1,78 @@ +'use client'; + +import * as React from 'react'; +import { motion, type Variants } from 'motion/react'; + +import { + getVariants, + useAnimateIconContext, + IconWrapper, + type IconProps, +} from '@/components/animate-ui/icons/icon'; + +type ChevronRightProps = IconProps; + +const animations = { + default: { + path: { + initial: { + x: 0, + transition: { duration: 0.3, ease: 'easeInOut' }, + }, + animate: { + x: 4, + transition: { duration: 0.3, ease: 'easeInOut' }, + }, + }, + } satisfies Record, + 'default-loop': { + path: { + initial: { + x: 0, + }, + animate: { + x: [0, 4, 0], + transition: { duration: 0.6, ease: 'easeInOut' }, + }, + }, + } satisfies Record, +} as const; + +function IconComponent({ size, ...props }: ChevronRightProps) { + const { controls } = useAnimateIconContext(); + const variants = getVariants(animations); + + return ( + + + + ); +} + +function ChevronRight(props: ChevronRightProps) { + return ; +} + +export { + animations, + ChevronRight, + ChevronRight as ChevronRightIcon, + type ChevronRightProps, + type ChevronRightProps as ChevronRightIconProps, +}; diff --git a/frontend/components/animate-ui/icons/circle-check.tsx b/frontend/components/animate-ui/icons/circle-check.tsx new file mode 100644 index 00000000..e2c05375 --- /dev/null +++ b/frontend/components/animate-ui/icons/circle-check.tsx @@ -0,0 +1,82 @@ +'use client'; + +import * as React from 'react'; +import { motion, type Variants } from 'motion/react'; + +import { + getVariants, + useAnimateIconContext, + IconWrapper, + type IconProps, +} from '@/components/animate-ui/icons/icon'; + +type CircleCheckProps = IconProps; + +const animations = { + default: { + circle: {}, + path: { + initial: { + pathLength: 1, + opacity: 1, + scale: 1, + }, + animate: { + pathLength: [0, 1], + opacity: [0, 1], + scale: [1, 1.1, 1], + transition: { + duration: 0.6, + ease: 'easeInOut', + }, + }, + }, + } satisfies Record, +} as const; + +function IconComponent({ size, ...props }: CircleCheckProps) { + const { controls } = useAnimateIconContext(); + const variants = getVariants(animations); + + return ( + + + + + ); +} + +function CircleCheck(props: CircleCheckProps) { + return ; +} + +export { + animations, + CircleCheck, + CircleCheck as CircleCheckIcon, + type CircleCheckProps, + type CircleCheckProps as CircleCheckIconProps, +}; diff --git a/frontend/components/animate-ui/icons/icon.tsx b/frontend/components/animate-ui/icons/icon.tsx new file mode 100644 index 00000000..02a90bab --- /dev/null +++ b/frontend/components/animate-ui/icons/icon.tsx @@ -0,0 +1,655 @@ +'use client'; + +import * as React from 'react'; +import { + motion, + useAnimation, + type SVGMotionProps, + type UseInViewOptions, + type LegacyAnimationControls, + type Variants, + type HTMLMotionProps, +} from 'motion/react'; + +import { cn } from '@/lib/utils'; +import { useIsInView } from '@/hooks/use-is-in-view'; +import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot'; + +const staticAnimations = { + path: { + initial: { pathLength: 1 }, + animate: { + pathLength: [0.05, 1], + transition: { + duration: 0.8, + ease: 'easeInOut', + }, + }, + } as Variants, + 'path-loop': { + initial: { pathLength: 1 }, + animate: { + pathLength: [1, 0.05, 1], + transition: { + duration: 1.6, + ease: 'easeInOut', + }, + }, + } as Variants, +} as const; + +type StaticAnimations = keyof typeof staticAnimations; +type TriggerProp = boolean | StaticAnimations | T; +type Trigger = TriggerProp; + +type AnimateIconContextValue = { + controls: LegacyAnimationControls | undefined; + animation: StaticAnimations | string; + loop: boolean; + loopDelay: number; + active: boolean; + animate?: Trigger; + initialOnAnimateEnd?: boolean; + completeOnStop?: boolean; + persistOnAnimateEnd?: boolean; + delay?: number; +}; + +type DefaultIconProps = { + animate?: TriggerProp; + animateOnHover?: TriggerProp; + animateOnTap?: TriggerProp; + animateOnView?: TriggerProp; + animateOnViewMargin?: UseInViewOptions['margin']; + animateOnViewOnce?: boolean; + animation?: T | StaticAnimations; + loop?: boolean; + loopDelay?: number; + initialOnAnimateEnd?: boolean; + completeOnStop?: boolean; + persistOnAnimateEnd?: boolean; + delay?: number; +}; + +type AnimateIconProps = WithAsChild< + HTMLMotionProps<'span'> & + DefaultIconProps & { + children: React.ReactNode; + asChild?: boolean; + } +>; + +type IconProps = DefaultIconProps & + Omit, 'animate'> & { + size?: number; + }; + +type IconWrapperProps = IconProps & { + icon: React.ComponentType>; +}; + +const AnimateIconContext = React.createContext( + null, +); + +function useAnimateIconContext() { + const context = React.useContext(AnimateIconContext); + if (!context) + return { + controls: undefined, + animation: 'default', + loop: undefined, + loopDelay: undefined, + active: undefined, + animate: undefined, + initialOnAnimateEnd: undefined, + completeOnStop: undefined, + persistOnAnimateEnd: undefined, + delay: undefined, + }; + return context; +} + +function composeEventHandlers>( + theirs?: (event: E) => void, + ours?: (event: E) => void, +) { + return (event: E) => { + theirs?.(event); + ours?.(event); + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyProps = Record; + +function AnimateIcon({ + asChild = false, + animate = false, + animateOnHover = false, + animateOnTap = false, + animateOnView = false, + animateOnViewMargin = '0px', + animateOnViewOnce = true, + animation = 'default', + loop = false, + loopDelay = 0, + initialOnAnimateEnd = false, + completeOnStop = false, + persistOnAnimateEnd = false, + delay = 0, + children, + ...props +}: AnimateIconProps) { + const controls = useAnimation(); + + const [localAnimate, setLocalAnimate] = React.useState(() => { + if (animate === undefined || animate === false) return false; + return delay <= 0; + }); + const [currentAnimation, setCurrentAnimation] = React.useState< + string | StaticAnimations + >(typeof animate === 'string' ? animate : animation); + const [status, setStatus] = React.useState<'initial' | 'animate'>('initial'); + + const delayRef = React.useRef | null>(null); + const loopDelayRef = React.useRef | null>(null); + const isAnimateInProgressRef = React.useRef(false); + const animateEndPromiseRef = React.useRef | null>(null); + const resolveAnimateEndRef = React.useRef<(() => void) | null>(null); + const activeRef = React.useRef(localAnimate); + + const runGenRef = React.useRef(0); + const cancelledRef = React.useRef(false); + + const bumpGeneration = React.useCallback(() => { + runGenRef.current++; + }, []); + + const startAnimation = React.useCallback( + (trigger: TriggerProp) => { + const next = typeof trigger === 'string' ? trigger : animation; + bumpGeneration(); + if (delayRef.current) { + clearTimeout(delayRef.current); + delayRef.current = null; + } + setCurrentAnimation(next); + if (delay > 0) { + setLocalAnimate(false); + delayRef.current = setTimeout(() => { + setLocalAnimate(true); + }, delay); + } else { + setLocalAnimate(true); + } + }, + [animation, delay, bumpGeneration], + ); + + const stopAnimation = React.useCallback(() => { + bumpGeneration(); + if (delayRef.current) { + clearTimeout(delayRef.current); + delayRef.current = null; + } + if (loopDelayRef.current) { + clearTimeout(loopDelayRef.current); + loopDelayRef.current = null; + } + setLocalAnimate(false); + }, [bumpGeneration]); + + React.useEffect(() => { + activeRef.current = localAnimate; + }, [localAnimate]); + + React.useEffect(() => { + if (animate === undefined) return; + setCurrentAnimation(typeof animate === 'string' ? animate : animation); + if (animate) startAnimation(animate as TriggerProp); + else stopAnimation(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [animate]); + + React.useEffect(() => { + return () => { + if (delayRef.current) clearTimeout(delayRef.current); + if (loopDelayRef.current) clearTimeout(loopDelayRef.current); + }; + }, []); + + const viewOuterRef = React.useRef(null); + const { ref: inViewRef, isInView } = useIsInView(viewOuterRef, { + inView: !!animateOnView, + inViewOnce: animateOnViewOnce, + inViewMargin: animateOnViewMargin, + }); + + const startAnim = React.useCallback( + async (anim: 'initial' | 'animate', method: 'start' | 'set' = 'start') => { + try { + await controls[method](anim); + setStatus(anim); + } catch { + return; + } + }, + [controls], + ); + + React.useEffect(() => { + if (!animateOnView) return; + if (isInView) startAnimation(animateOnView); + else stopAnimation(); + }, [isInView, animateOnView, startAnimation, stopAnimation]); + + React.useEffect(() => { + const gen = ++runGenRef.current; + cancelledRef.current = false; + + async function run() { + if (cancelledRef.current || gen !== runGenRef.current) { + await startAnim('initial'); + return; + } + + if (!localAnimate) { + if ( + completeOnStop && + isAnimateInProgressRef.current && + animateEndPromiseRef.current + ) { + try { + await animateEndPromiseRef.current; + } catch { + // noop + } + } + if (!persistOnAnimateEnd) { + if (cancelledRef.current || gen !== runGenRef.current) { + await startAnim('initial'); + return; + } + await startAnim('initial'); + } + return; + } + + if (loop) { + if (cancelledRef.current || gen !== runGenRef.current) { + await startAnim('initial'); + return; + } + await startAnim('initial', 'set'); + } + + isAnimateInProgressRef.current = true; + animateEndPromiseRef.current = new Promise((resolve) => { + resolveAnimateEndRef.current = resolve; + }); + + if (cancelledRef.current || gen !== runGenRef.current) { + isAnimateInProgressRef.current = false; + resolveAnimateEndRef.current?.(); + resolveAnimateEndRef.current = null; + animateEndPromiseRef.current = null; + await startAnim('initial'); + return; + } + + await startAnim('animate'); + + if (cancelledRef.current || gen !== runGenRef.current) { + isAnimateInProgressRef.current = false; + resolveAnimateEndRef.current?.(); + resolveAnimateEndRef.current = null; + animateEndPromiseRef.current = null; + await startAnim('initial'); + return; + } + + isAnimateInProgressRef.current = false; + resolveAnimateEndRef.current?.(); + resolveAnimateEndRef.current = null; + animateEndPromiseRef.current = null; + + if (initialOnAnimateEnd) { + if (cancelledRef.current || gen !== runGenRef.current) { + await startAnim('initial'); + return; + } + await startAnim('initial', 'set'); + } + + if (loop) { + if (loopDelay > 0) { + await new Promise((resolve) => { + loopDelayRef.current = setTimeout(() => { + loopDelayRef.current = null; + resolve(); + }, loopDelay); + }); + + if (cancelledRef.current || gen !== runGenRef.current) { + await startAnim('initial'); + return; + } + if (!activeRef.current) { + if (status !== 'initial' && !persistOnAnimateEnd) + await startAnim('initial'); + return; + } + } else { + if (!activeRef.current) { + if (status !== 'initial' && !persistOnAnimateEnd) + await startAnim('initial'); + return; + } + } + if (cancelledRef.current || gen !== runGenRef.current) { + await startAnim('initial'); + return; + } + await run(); + } + } + + void run(); + + return () => { + cancelledRef.current = true; + if (delayRef.current) { + clearTimeout(delayRef.current); + delayRef.current = null; + } + if (loopDelayRef.current) { + clearTimeout(loopDelayRef.current); + loopDelayRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [localAnimate, controls]); + + const childProps = ( + React.isValidElement(children) ? (children as React.ReactElement).props : {} + ) as AnyProps; + + const handleMouseEnter = composeEventHandlers>( + childProps.onMouseEnter, + () => { + if (animateOnHover) startAnimation(animateOnHover); + }, + ); + + const handleMouseLeave = composeEventHandlers>( + childProps.onMouseLeave, + () => { + if (animateOnHover || animateOnTap) stopAnimation(); + }, + ); + + const handlePointerDown = composeEventHandlers< + React.PointerEvent + >(childProps.onPointerDown, () => { + if (animateOnTap) startAnimation(animateOnTap); + }); + + const handlePointerUp = composeEventHandlers>( + childProps.onPointerUp, + () => { + if (animateOnTap) stopAnimation(); + }, + ); + + const content = asChild ? ( + + {children} + + ) : ( + + {children} + + ); + + return ( + + {content} + + ); +} + +const pathClassName = + "[&_[stroke-dasharray='1px_1px']]:![stroke-dasharray:1px_0px]"; + +function IconWrapper({ + size = 28, + animation: animationProp, + animate, + animateOnHover, + animateOnTap, + animateOnView, + animateOnViewMargin, + animateOnViewOnce, + icon: IconComponent, + loop, + loopDelay, + persistOnAnimateEnd, + initialOnAnimateEnd, + delay, + completeOnStop, + className, + ...props +}: IconWrapperProps) { + const context = React.useContext(AnimateIconContext); + + if (context) { + const { + controls, + animation: parentAnimation, + loop: parentLoop, + loopDelay: parentLoopDelay, + active: parentActive, + animate: parentAnimate, + persistOnAnimateEnd: parentPersistOnAnimateEnd, + initialOnAnimateEnd: parentInitialOnAnimateEnd, + delay: parentDelay, + completeOnStop: parentCompleteOnStop, + } = context; + + const hasOverrides = + animate !== undefined || + animateOnHover !== undefined || + animateOnTap !== undefined || + animateOnView !== undefined || + loop !== undefined || + loopDelay !== undefined || + initialOnAnimateEnd !== undefined || + persistOnAnimateEnd !== undefined || + delay !== undefined || + completeOnStop !== undefined; + + if (hasOverrides) { + const inheritedAnimate: Trigger = parentActive + ? (animationProp ?? parentAnimation ?? 'default') + : false; + + const finalAnimate: Trigger = (animate ?? + parentAnimate ?? + inheritedAnimate) as Trigger; + + return ( + + + + ); + } + + const animationToUse = animationProp ?? parentAnimation; + const loopToUse = parentLoop; + const loopDelayToUse = parentLoopDelay; + + return ( + + + + ); + } + + if ( + animate !== undefined || + animateOnHover !== undefined || + animateOnTap !== undefined || + animateOnView !== undefined || + animationProp !== undefined + ) { + return ( + + + + ); + } + + return ( + + ); +} + +function getVariants< + V extends { default: T; [key: string]: T }, + T extends Record, +>(animations: V): T { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { animation: animationType } = useAnimateIconContext(); + + let result: T; + + if (animationType in staticAnimations) { + const variant = staticAnimations[animationType as StaticAnimations]; + result = {} as T; + for (const key in animations.default) { + if ( + (animationType === 'path' || animationType === 'path-loop') && + key.includes('group') + ) + continue; + result[key] = variant as T[Extract]; + } + } else { + result = (animations[animationType as keyof V] as T) ?? animations.default; + } + + return result; +} + +export { + pathClassName, + staticAnimations, + AnimateIcon, + IconWrapper, + useAnimateIconContext, + getVariants, + type IconProps, + type IconWrapperProps, + type AnimateIconProps, + type AnimateIconContextValue, +}; diff --git a/frontend/components/animate-ui/icons/send.tsx b/frontend/components/animate-ui/icons/send.tsx new file mode 100644 index 00000000..65d70f2c --- /dev/null +++ b/frontend/components/animate-ui/icons/send.tsx @@ -0,0 +1,92 @@ +'use client'; + +import * as React from 'react'; +import { motion, type Variants } from 'motion/react'; + +import { + getVariants, + useAnimateIconContext, + IconWrapper, + type IconProps, +} from '@/components/animate-ui/icons/icon'; + +type SendProps = IconProps; + +const animations = { + default: { + group: { + initial: { + scale: 1, + x: 0, + y: 0, + }, + animate: { + scale: [1, 0.8, 1, 1, 1], + x: [0, '-10%', '100%', '-125%', 0], + y: [0, '10%', '-100%', '125%', 0], + transition: { + default: { ease: 'easeInOut', duration: 1.2 }, + x: { + ease: 'easeInOut', + duration: 1.2, + times: [0, 0.25, 0.5, 0.5, 1], + }, + y: { + ease: 'easeInOut', + duration: 1.2, + times: [0, 0.25, 0.5, 0.5, 1], + }, + }, + }, + }, + path1: {}, + path2: {}, + } satisfies Record, +} as const; + +function IconComponent({ size, ...props }: SendProps) { + const { controls } = useAnimateIconContext(); + const variants = getVariants(animations); + + return ( + + + + + + + ); +} + +function Send(props: SendProps) { + return ; +} + +export { + animations, + Send, + Send as SendIcon, + type SendProps, + type SendProps as SendIconProps, +}; diff --git a/frontend/components/animate-ui/primitives/animate/slot.tsx b/frontend/components/animate-ui/primitives/animate/slot.tsx new file mode 100644 index 00000000..3142cc40 --- /dev/null +++ b/frontend/components/animate-ui/primitives/animate/slot.tsx @@ -0,0 +1,96 @@ +'use client'; + +import * as React from 'react'; +import { motion, isMotionComponent, type HTMLMotionProps } from 'motion/react'; +import { cn } from '@/lib/utils'; + +type AnyProps = Record; + +type DOMMotionProps = Omit< + HTMLMotionProps, + 'ref' +> & { ref?: React.Ref }; + +type WithAsChild = + | (Base & { asChild: true; children: React.ReactElement }) + | (Base & { asChild?: false | undefined }); + +type SlotProps = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + children?: any; +} & DOMMotionProps; + +function mergeRefs( + ...refs: (React.Ref | undefined)[] +): React.RefCallback { + return (node) => { + refs.forEach((ref) => { + if (!ref) return; + if (typeof ref === 'function') { + ref(node); + } else { + (ref as React.RefObject).current = node; + } + }); + }; +} + +function mergeProps( + childProps: AnyProps, + slotProps: DOMMotionProps, +): AnyProps { + const merged: AnyProps = { ...childProps, ...slotProps }; + + if (childProps.className || slotProps.className) { + merged.className = cn( + childProps.className as string, + slotProps.className as string, + ); + } + + if (childProps.style || slotProps.style) { + merged.style = { + ...(childProps.style as React.CSSProperties), + ...(slotProps.style as React.CSSProperties), + }; + } + + return merged; +} + +function Slot({ + children, + ref, + ...props +}: SlotProps) { + const isAlreadyMotion = + typeof children.type === 'object' && + children.type !== null && + isMotionComponent(children.type); + + const Base = React.useMemo( + () => + isAlreadyMotion + ? (children.type as React.ElementType) + : motion.create(children.type as React.ElementType), + [isAlreadyMotion, children.type], + ); + + if (!React.isValidElement(children)) return null; + + const { ref: childRef, ...childProps } = children.props as AnyProps; + + const mergedProps = mergeProps(childProps, props); + + return ( + , ref)} /> + ); +} + +export { + Slot, + type SlotProps, + type WithAsChild, + type DOMMotionProps, + type AnyProps, +}; diff --git a/frontend/components/animate-ui/primitives/buttons/button.tsx b/frontend/components/animate-ui/primitives/buttons/button.tsx new file mode 100644 index 00000000..bb518790 --- /dev/null +++ b/frontend/components/animate-ui/primitives/buttons/button.tsx @@ -0,0 +1,32 @@ +'use client'; + +import * as React from 'react'; +import { motion, type HTMLMotionProps } from 'motion/react'; + +import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot'; + +type ButtonProps = WithAsChild< + HTMLMotionProps<'button'> & { + hoverScale?: number; + tapScale?: number; + } +>; + +function Button({ + hoverScale = 1.05, + tapScale = 0.95, + asChild = false, + ...props +}: ButtonProps) { + const Component = asChild ? Slot : motion.button; + + return ( + + ); +} + +export { Button, type ButtonProps }; diff --git a/frontend/components/animate-ui/primitives/buttons/ripple.tsx b/frontend/components/animate-ui/primitives/buttons/ripple.tsx new file mode 100644 index 00000000..9b54d5e8 --- /dev/null +++ b/frontend/components/animate-ui/primitives/buttons/ripple.tsx @@ -0,0 +1,144 @@ +'use client'; + +import * as React from 'react'; +import { motion, type HTMLMotionProps } from 'motion/react'; + +import { getStrictContext } from '@/lib/utils/get-strict-context'; +import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot'; + +type Ripple = { + id: number; + x: number; + y: number; +}; + +type RippleButtonContextType = { + ripples: Ripple[]; + setRipples: (ripples: Ripple[]) => void; +}; + +const [RippleButtonProvider, useRippleButton] = + getStrictContext('RippleButtonContext'); + +type RippleButtonProps = WithAsChild< + HTMLMotionProps<'button'> & { + hoverScale?: number; + tapScale?: number; + } +>; + +function RippleButton({ + ref, + onClick, + hoverScale = 1, + tapScale = 0.95, + asChild = false, + style, + ...props +}: RippleButtonProps) { + const [ripples, setRipples] = React.useState([]); + const buttonRef = React.useRef(null); + React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement); + + const createRipple = React.useCallback( + (event: React.MouseEvent) => { + const button = buttonRef.current; + if (!button) return; + + const rect = button.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + const newRipple: Ripple = { + id: Date.now(), + x, + y, + }; + + setRipples((prev) => [...prev, newRipple]); + + setTimeout(() => { + setRipples((prev) => prev.filter((r) => r.id !== newRipple.id)); + }, 600); + }, + [], + ); + + const handleClick = React.useCallback( + (event: React.MouseEvent) => { + createRipple(event); + if (onClick) { + onClick(event); + } + }, + [createRipple, onClick], + ); + + const Component = asChild ? Slot : motion.button; + + return ( + + + + ); +} + +type RippleButtonRipplesProps = WithAsChild< + HTMLMotionProps<'span'> & { + color?: string; + scale?: number; + } +>; + +function RippleButtonRipples({ + color = 'var(--ripple-button-ripple-color)', + scale = 10, + transition = { duration: 0.6, ease: 'easeOut' }, + asChild = false, + style, + ...props +}: RippleButtonRipplesProps) { + const { ripples } = useRippleButton(); + + const Component = asChild ? Slot : motion.span; + + return ripples.map((ripple) => ( + + )); +} + +export { + RippleButton, + RippleButtonRipples, + type RippleButtonProps, + type RippleButtonRipplesProps, +}; diff --git a/frontend/components/animate-ui/primitives/radix/collapsible.tsx b/frontend/components/animate-ui/primitives/radix/collapsible.tsx new file mode 100644 index 00000000..ebeefe3a --- /dev/null +++ b/frontend/components/animate-ui/primitives/radix/collapsible.tsx @@ -0,0 +1,110 @@ +'use client'; + +import * as React from 'react'; +import { Collapsible as CollapsiblePrimitive } from 'radix-ui'; +import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react'; + +import { getStrictContext } from '@/lib/utils/get-strict-context'; +import { useControlledState } from '@/hooks/use-controlled-state'; + +type CollapsibleContextType = { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; +}; + +const [CollapsibleProvider, useCollapsible] = + getStrictContext('CollapsibleContext'); + +type CollapsibleProps = React.ComponentProps; + +function Collapsible(props: CollapsibleProps) { + const [isOpen, setIsOpen] = useControlledState({ + value: props?.open, + defaultValue: props?.defaultOpen, + onChange: props?.onOpenChange, + }); + + return ( + + + + ); +} + +type CollapsibleTriggerProps = React.ComponentProps< + typeof CollapsiblePrimitive.Trigger +>; + +function CollapsibleTrigger(props: CollapsibleTriggerProps) { + return ( + + ); +} + +type CollapsibleContentProps = Omit< + React.ComponentProps, + 'asChild' | 'forceMount' +> & + HTMLMotionProps<'div'> & { + keepRendered?: boolean; + }; + +function CollapsibleContent({ + keepRendered = false, + transition = { duration: 0.35, ease: 'easeInOut' }, + ...props +}: CollapsibleContentProps) { + const { isOpen } = useCollapsible(); + + return ( + + {keepRendered ? ( + + + + ) : ( + isOpen && ( + + + + ) + )} + + ); +} + +export { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, + useCollapsible, + type CollapsibleProps, + type CollapsibleTriggerProps, + type CollapsibleContentProps, + type CollapsibleContextType, +}; diff --git a/frontend/components/animate-ui/primitives/texts/counting-number.tsx b/frontend/components/animate-ui/primitives/texts/counting-number.tsx new file mode 100644 index 00000000..b3e753f8 --- /dev/null +++ b/frontend/components/animate-ui/primitives/texts/counting-number.tsx @@ -0,0 +1,136 @@ +'use client'; + +import * as React from 'react'; +import { useMotionValue, useSpring, type SpringOptions } from 'motion/react'; + +import { + useIsInView, + type UseIsInViewOptions, +} from '@/hooks/use-is-in-view'; + +type CountingNumberProps = Omit, 'children'> & { + number: number; + fromNumber?: number; + padStart?: boolean; + decimalSeparator?: string; + decimalPlaces?: number; + transition?: SpringOptions; + delay?: number; + initiallyStable?: boolean; + thousandSeparator?: string; +} & UseIsInViewOptions; + +function CountingNumber({ + ref, + number, + fromNumber = 0, + padStart = false, + inView = false, + inViewMargin = '0px', + inViewOnce = true, + decimalSeparator = '.', + transition = { stiffness: 200, damping: 30 }, + decimalPlaces = 0, + delay = 0, + initiallyStable = false, + thousandSeparator = '', + ...props +}: CountingNumberProps) { + const { ref: localRef, isInView } = useIsInView( + ref as React.Ref, + { + inView, + inViewOnce, + inViewMargin, + }, + ); + + const numberStr = number.toString(); + const decimals = + typeof decimalPlaces === 'number' + ? decimalPlaces + : numberStr.includes('.') + ? (numberStr.split('.')[1]?.length ?? 0) + : 0; + + const motionVal = useMotionValue(initiallyStable ? number : fromNumber); + const springVal = useSpring(motionVal, transition); + + React.useEffect(() => { + const timeoutId = setTimeout(() => { + if (isInView) motionVal.set(number); + }, delay); + + return () => clearTimeout(timeoutId); + }, [isInView, number, motionVal, delay]); + + React.useEffect(() => { + const unsubscribe = springVal.on('change', (latest) => { + if (localRef.current) { + let formatted = + decimals > 0 + ? latest.toFixed(decimals) + : Math.round(latest).toString(); + + if (decimals > 0) { + formatted = formatted.replace('.', decimalSeparator); + } + + if (padStart) { + const finalIntLength = Math.floor(Math.abs(number)).toString().length; + const [intPart, fracPart] = formatted.split(decimalSeparator); + const paddedInt = intPart?.padStart(finalIntLength, '0') ?? ''; + formatted = fracPart + ? `${ paddedInt }${ decimalSeparator }${ fracPart }` + : paddedInt; + } + + if (thousandSeparator) { + const [intPart, fracPart] = formatted.split(decimalSeparator); + const regex = /\B(?=(\d{3})+(?!\d))/g; + const separatedInt = intPart.replace(regex, thousandSeparator); + formatted = fracPart + ? `${ separatedInt }${ decimalSeparator }${ fracPart }` + : separatedInt; + } + + localRef.current.textContent = formatted; + } + }); + return () => unsubscribe(); + }, [springVal, decimals, padStart, number, decimalSeparator, localRef, thousandSeparator]); + + const finalIntLength = Math.floor(Math.abs(number)).toString().length; + + const formatValue = (val: number) => { + let out = decimals > 0 ? val.toFixed(decimals) : Math.round(val).toString(); + if (decimals > 0) out = out.replace('.', decimalSeparator); + if (padStart) { + const [intPart, fracPart] = out.split(decimalSeparator); + const paddedInt = (intPart ?? '').padStart(finalIntLength, '0'); + out = fracPart ? `${ paddedInt }${ decimalSeparator }${ fracPart }` : paddedInt; + } + if (thousandSeparator) { + const [intPart, fracPart] = out.split(decimalSeparator); + const regex = /\B(?=(\d{3})+(?!\d))/g; + const separatedInt = intPart.replace(regex, thousandSeparator); + out = fracPart ? `${ separatedInt }${ decimalSeparator }${ fracPart }` : separatedInt; + } + return out; + }; + + const zeroText = padStart + ? '0'.padStart(finalIntLength, '0') + + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '') + : '0' + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : ''); + + const initialText = initiallyStable ? formatValue(number) : zeroText; + + return ( + + {initialText} + + ); +} + +export { CountingNumber, type CountingNumberProps }; diff --git a/frontend/components/auth/login-form.tsx b/frontend/components/auth/login-form.tsx new file mode 100644 index 00000000..d48dcb91 --- /dev/null +++ b/frontend/components/auth/login-form.tsx @@ -0,0 +1,182 @@ +"use client" + +import { useState, useEffect } from "react" +import { toast } from "sonner" +import { Spinner } from "@/components/ui/spinner" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { motion, useAnimation } from "motion/react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion" +import { SquareArrowUpRight } from 'lucide-react'; + +import { cn } from "@/lib/utils" +import services from "@/lib/services" +import { termsSections } from "@/components/common/docs/terms" +import { privacySections } from "@/components/common/docs/privacy" + + +/** + * 登录表单组件 + * 显示登录表单和登录按钮 + * + * @example + * ```tsx + * + * ``` + * @param {React.ComponentProps<"div">} props - 组件属性 + * @param {string} className - 组件类名 + * @returns {React.ReactNode} 登录表单组件 + */ +export function LoginForm({ + className, + ...props +}: React.ComponentProps<"div">) { + const [isLoading, setIsLoading] = useState(false) + const [hasAgreed, setHasAgreed] = useState(false) + const controls = useAnimation() + + + useEffect(() => { + const agreed = localStorage.getItem("loginPromptAgreed") === "true" + if (agreed) { + setHasAgreed(true) + } + }, []) + + const handleAgreementChange = (checked: boolean | string) => { + const isChecked = checked === true + setHasAgreed(isChecked) + if (isChecked) { + localStorage.setItem("loginPromptAgreed", "true") + } else { + localStorage.removeItem("loginPromptAgreed") + } + } + + /* 处理登录 */ + const handleLogin = async () => { + if (!hasAgreed) { + toast.error("请先阅读并勾选服务条款和隐私政策") + controls.start({ + x: [0, -4, 4, -4, 4, 0], + color: ["#ef4444", "inherit"], + transition: { duration: 0.5 } + }) + return + } + + setIsLoading(true) + try { + await services.auth.initiateLogin() + } catch (error) { + setIsLoading(false) + console.error('Login error:', error) + const message = error instanceof Error ? error.message : "登录失败,请重试" + toast.error(message, { + duration: 5000, + description: error instanceof Error && error.name === 'NetworkError' + ? '请确认后端服务已启动' + : undefined + }) + } + } + + return ( +
+
+ +
+ + + + + +
+ ) +} diff --git a/frontend/components/auth/login-page.tsx b/frontend/components/auth/login-page.tsx new file mode 100644 index 00000000..5bab9f08 --- /dev/null +++ b/frontend/components/auth/login-page.tsx @@ -0,0 +1,390 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { motion, AnimatePresence } from "motion/react" +import { useRouter, useSearchParams } from "next/navigation" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { Spinner } from "@/components/ui/spinner" +import { LoginForm } from "@/components/auth/login-form" +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp" +import { Check } from "lucide-react" + +import { AuroraBackground } from "@/components/ui/aurora-background" +import services from "@/lib/services" +import type { ApiResponse } from "@/lib/services/core/types" + + +/** + * 登录页面组件 + * 显示登录表单和登录按钮 + * + * @example + * ```tsx + * + * ``` + * @returns {React.ReactNode} 登录页面组件 + */ +export function LoginPage() { + const router = useRouter() + const searchParams = useSearchParams() + + /* 处理OAuth回调 */ + const [isProcessingCallback, setIsProcessingCallback] = useState(() => { + const state = searchParams.get('state') + const code = searchParams.get('code') + return !!(state && code) + }) + const [isCheckingSession, setIsCheckingSession] = useState(() => !searchParams.get('state') || !searchParams.get('code')) + + const [loginSuccess, setLoginSuccess] = useState(false) + const [needsPayKeySetup, setNeedsPayKeySetup] = useState(false) + + const [payKey, setPayKey] = useState("") + const [confirmPayKey, setConfirmPayKey] = useState("") + const [isSubmittingPayKey, setIsSubmittingPayKey] = useState(false) + const [setupStep, setSetupStep] = useState<'password' | 'confirm'>('password') + + const isPayKeyValid = payKey.length === 6 && /^\d{6}$/.test(payKey) + const isConfirmValid = confirmPayKey.length === 6 && /^\d{6}$/.test(confirmPayKey) + const passwordsMatch = payKey === confirmPayKey + + const resolveRedirectTarget = useCallback(() => { + const callbackUrl = searchParams.get('callbackUrl') + const storedRedirect = sessionStorage.getItem('redirect_after_login') + const target = callbackUrl || storedRedirect || '/home' + + if (storedRedirect) { + sessionStorage.removeItem('redirect_after_login') + } + + return target + }, [searchParams]) + + /* 安全密码输入 */ + const handlePayKeyChange = (value: string) => { + const numericValue = value.replace(/\D/g, '') + setPayKey(numericValue) + } + + /* 确认安全密码 */ + const handleConfirmPayKeyChange = (value: string) => { + const numericValue = value.replace(/\D/g, '') + setConfirmPayKey(numericValue) + } + + /* 登录页兜底:已登录用户直接跳转 */ + useEffect(() => { + const state = searchParams.get('state') + const code = searchParams.get('code') + + if (state && code) { + setIsCheckingSession(false) + return + } + + let cancelled = false + + const checkExistingSession = async () => { + setIsCheckingSession(true) + + try { + const response = await fetch('/api/v1/oauth/user-info', { + credentials: 'include', + cache: 'no-store', + }) + + if (cancelled) return + + if (response.ok) { + await response.json() as ApiResponse + router.replace(resolveRedirectTarget()) + return + } + + if (response.status !== 401) { + console.error('Session probe failed:', response.status) + } + } catch (error) { + if (!cancelled) { + console.error('Session probe error:', error) + } + } finally { + if (!cancelled) { + setIsCheckingSession(false) + } + } + } + + checkExistingSession() + + return () => { + cancelled = true + } + }, [router, searchParams, resolveRedirectTarget]) + + /* 回调逻辑 */ + useEffect(() => { + const handleOAuthCallback = async () => { + const state = searchParams.get('state') + const code = searchParams.get('code') + + if (state && code) { + setIsProcessingCallback(true) + try { + await services.auth.handleCallback({ state, code }) + + const user = await services.auth.getUserInfo() + + if (!user.is_pay_key) { + setNeedsPayKeySetup(true) + } else { + setLoginSuccess(true) + toast.success("登录成功") + + setTimeout(() => { + router.replace(resolveRedirectTarget()) + }, 1500) + } + } catch (error) { + console.error('OAuth callback error:', error) + toast.error(error instanceof Error ? error.message : "登录失败,请重试") + setIsProcessingCallback(false) + router.replace('/login') + } + } + } + handleOAuthCallback() + }, [searchParams, router, resolveRedirectTarget]) + + /* 安全密码设置 */ + const handlePayKeySubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (setupStep === 'password') { + if (!isPayKeyValid) { + toast.error("安全密码必须为6位数字") + return + } + setSetupStep('confirm') + } else { + if (!isConfirmValid) { + toast.error("确认密码必须为6位数字") + return + } + + if (!passwordsMatch) { + toast.error("两次输入的安全密码不一致") + setSetupStep('password') + setConfirmPayKey("") + return + } + + setIsSubmittingPayKey(true) + try { + await services.user.updatePayKey(payKey) + toast.success("安全密码设置成功") + setNeedsPayKeySetup(false) + setLoginSuccess(true) + setPayKey("") + setConfirmPayKey("") + setSetupStep('password') + setTimeout(() => { + router.replace(resolveRedirectTarget()) + }, 1500) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "设置安全密码失败" + toast.error(errorMessage) + setSetupStep('password') + setConfirmPayKey("") + } finally { + setIsSubmittingPayKey(false) + } + } + } + + /* 渲染 PayKey 设置界面 */ + const renderPayKeySetup = (key: string) => ( + +
+

+ {setupStep === 'password' ? '设置安全密码' : '确认安全密码'} +

+

+ {setupStep === 'password' + ? '请设置6位数字安全密码,用于安全操作' + : '请再次输入密码进行确认'} +

+
+ +
+
+ {setupStep === 'password' ? ( + + + {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} + + + ) : ( +
+ + + {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} + + + {!passwordsMatch && confirmPayKey.length === 6 && ( + + 两次输入的密码不一致 + + )} +
+ )} +
+ +
+ {setupStep === 'confirm' && ( + + )} + +
+
+
+ ) + + return ( + + +
+

+ LINUX DO Credit +

+

+ 简单、安全,专为社区设计 +

+
+ + + {isProcessingCallback || isCheckingSession ? ( + + {isCheckingSession ? ( +
+
+ +
+
+

正在检查登录状态

+

请稍候,我们正在确认当前会话...

+
+
+ ) : needsPayKeySetup ? ( + renderPayKeySetup("oauth-pay-key-setup") + ) : loginSuccess ? ( +
+ + + +
+

登录成功

+

正在跳转至控制台...

+
+
+ ) : ( +
+
+ +
+
+

正在验证凭据

+

请稍候,我们正在为您建立安全会话...

+
+
+ )} +
+ ) : ( + + + + )} +
+ +
+ © {new Date().getFullYear()} LINUX DO Credit. 版权所有 +
+
+
+ ) +} diff --git a/frontend/components/common/admin/credit.tsx b/frontend/components/common/admin/credit.tsx new file mode 100644 index 00000000..8e441f83 --- /dev/null +++ b/frontend/components/common/admin/credit.tsx @@ -0,0 +1,378 @@ +"use client" + +import * as React from "react" +import { ManagePage, ManageDetailPanel } from "@/components/common/general/manage-pannel" +import { Input } from "@/components/ui/input" + +import { formatDateTime } from "@/lib/utils" +import type { UserPayConfig } from "@/lib/services" +import { useAdmin } from "@/contexts/admin-context" + + +/** + * 积分配置详情面板组件 + * 显示积分配置的详细信息和编辑面板 + * + * @example + * ```tsx + * + * ``` + * @param {UserPayConfig} config - 积分配置 + * @param {Partial} editData - 编辑数据 + * @param {function} onEditDataChange - 编辑数据改变回调 + * @param {function} onSave - 保存回调 + * @param {boolean} saving - 是否正在保存 + * @returns {React.ReactNode} 积分配置详情面板组件 + */ +function PayConfigDetailPanel({ + config, + editData, + onEditDataChange, + onSave, + saving +}: { + config: UserPayConfig | null + editData: Partial + onEditDataChange: (field: keyof UserPayConfig, value: UserPayConfig[keyof UserPayConfig]) => void + onSave: () => void + saving: boolean +}) { + + + return ( + +
+
+
+ +

Level {config?.level}

+
+ +
+ +

{config?.id}

+
+ +
+ +

{config ? formatDateTime(config.created_at) : ''}

+
+ +
+ +

{config ? formatDateTime(config.updated_at) : ''}

+
+ + + + +
+ +
+
+ +
+ { + const value = e.target.value + if (value === '') { + onEditDataChange('min_score', 0) + return + } + + const numValue = parseInt(value) + if (isNaN(numValue)) { + return + } + + if (numValue >= 0) { + onEditDataChange('min_score', numValue) + } + }} + className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]" + /> +

LDC

+
+
+ +
+ +
+ { + const value = e.target.value + if (value === '') { + onEditDataChange('max_score', null) + return + } + + const numValue = parseInt(value) + if (isNaN(numValue)) { + return + } + + if (numValue >= 0) { + onEditDataChange('max_score', numValue) + } + }} + className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]" + /> +

LDC

+
+
+ +
+ +
+ { + const value = e.target.value + if (value === '') { + onEditDataChange('fee_rate', '0') + return + } + + const numValue = parseInt(value) + if (isNaN(numValue)) { + return + } + + if (numValue >= 0 && numValue <= 100) { + onEditDataChange('fee_rate', (numValue / 100).toString()) + } + }} + className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]" + /> +

%

+
+
+ +
+ +
+ { + const value = e.target.value + if (value === '') { + onEditDataChange('score_rate', '0') + return + } + + const numValue = parseInt(value) + if (isNaN(numValue)) { + return + } + + if (numValue >= 0 && numValue <= 100) { + onEditDataChange('score_rate', (numValue / 100).toString()) + } + }} + className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]" + /> +

%

+
+
+ +
+ +
+ { + const value = e.target.value + if (value === '') { + onEditDataChange('distribute_rate', '0') + return + } + + const numValue = parseInt(value) + if (isNaN(numValue)) { + return + } + + if (numValue >= 0 && numValue <= 100) { + onEditDataChange('distribute_rate', (numValue / 100).toString()) + } + }} + className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]" + /> +

%

+
+
+ +
+ +
+ { + const value = e.target.value + if (value === '') { + onEditDataChange('daily_limit', null) + return + } + + const numValue = parseInt(value) + if (isNaN(numValue)) { + return + } + + if (numValue >= 0) { + onEditDataChange('daily_limit', numValue) + } + }} + className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]" + /> +

LDC

+
+
+
+
+
+ ) +} + + + + + +/** + * 用户积分配置管理组件 + * + * @example + * ```tsx + * + * ``` + * @returns {React.ReactNode} 用户积分配置管理组件 + */ +export function UserPayConfigs() { + const { + userPayConfigs: configs, + userPayConfigsLoading: loading, + userPayConfigsError: error, + refetchUserPayConfigs, + updateUserPayConfig, + deleteUserPayConfig + } = useAdmin() + + const getInitialEditData = (config: UserPayConfig) => ({ + min_score: config.min_score, + max_score: config.max_score, + daily_limit: config.daily_limit, + fee_rate: config.fee_rate.toString(), + score_rate: config.score_rate.toString(), + distribute_rate: config.distribute_rate.toString(), + }) + + const handleSave = async (config: UserPayConfig, editData: Partial) => { + if (!config) return + + await updateUserPayConfig(config.id, { + min_score: editData.min_score ?? config.min_score, + max_score: editData.max_score, + daily_limit: editData.daily_limit, + fee_rate: editData.fee_rate?.toString() ?? config.fee_rate.toString(), + score_rate: editData.score_rate?.toString() ?? config.score_rate.toString(), + distribute_rate: editData.distribute_rate?.toString() ?? config.distribute_rate.toString(), + }) + await refetchUserPayConfigs() + } + + const handleDelete = async (config: UserPayConfig) => { + await deleteUserPayConfig(config.id) + } + + return ( + + title="积分配置" + data={configs} + loading={loading} + error={error} + onReload={refetchUserPayConfigs} + getInitialEditData={getInitialEditData} + onSave={handleSave} + onDelete={handleDelete} + getId={(config) => config.id} + emptyDescription="未发现积分配置" + loadingDescription="配置加载中" + columns={[ + { header: "等级", cell: (item) => Level {item.level}, width: "min-w-[80px]", align: "left" }, + { header: "最低分", cell: (item) => item.min_score, width: "min-w-[200px]", align: "left" }, + { header: "最高分", cell: (item) => item.max_score || "无限制", width: "min-w-[200px]", align: "left" }, + { header: "每日限额", cell: (item) => item.daily_limit ? `LDC ${ item.daily_limit.toLocaleString() }` : "无限制", width: "min-w-[200px]", align: "left" }, + { header: "费率", cell: (item) => `${ (Number(item.fee_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" }, + { header: "分数转化率", cell: (item) => `${ (Number(item.score_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" }, + { header: "分发费率", cell: (item) => `${ (Number(item.distribute_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" }, + { header: "更新时间", cell: (item) => {formatDateTime(item.updated_at)}, width: "min-w-[200px]", align: "left" }, + ]} + renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => ( + + )} + /> + ) +} diff --git a/frontend/components/common/admin/system.tsx b/frontend/components/common/admin/system.tsx new file mode 100644 index 00000000..a4f79518 --- /dev/null +++ b/frontend/components/common/admin/system.tsx @@ -0,0 +1,190 @@ +"use client" + +import * as React from "react" +import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" +import { ManagePage, ManageDetailPanel } from "@/components/common/general/manage-pannel" + +import { formatDateTime } from "@/lib/utils" +import type { SystemConfig } from "@/lib/services" +import { useAdmin } from "@/contexts/admin-context" + + +/** + * 系统配置 + * 显示系统配置的详细信息和编辑面板 + * + * @example + * ```tsx + * + * ``` + * @param {SystemConfig} config - 系统配置 + * @param {Partial} editData - 编辑数据 + * @param {function} onEditDataChange - 编辑数据改变回调 + * @param {function} onSave - 保存回调 + * @param {boolean} saving - 是否正在保存 + * @returns {React.ReactNode} 系统配置详情面板组件 + */ +function SystemConfigDetailPanel({ + config, + editData, + onEditDataChange, + onSave, + saving +}: { + config: SystemConfig | null + editData: Partial + onEditDataChange: (field: keyof SystemConfig, value: SystemConfig[keyof SystemConfig]) => void + onSave: () => void + saving: boolean +}) { + + return ( + +
+
+
+ +

{config?.key}

+
+ +
+ +
+ {config?.key.endsWith('_enabled') ? ( + { + onEditDataChange('value', checked ? 'true' : 'false') + }} + /> + ) : ( + { + const value = e.target.value + if (value === '') { + onEditDataChange('value', '') + return + } + onEditDataChange('value', value) + }} + className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-inner-spin-button]" + style={{ + MozAppearance: 'textfield' + }} + /> + )} +
+
+ +
+ +
+ { + const value = e.target.value + onEditDataChange('description', value) + }} + className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px]" + /> +
+
+ +
+ +

{config ? formatDateTime(config.created_at) : ''}

+
+
+
+
+ ) +} + + + +/** + * 系统配置管理组件 + * + * @example + * ```tsx + * + * ``` + * @returns {React.ReactNode} 系统配置管理组件 + */ +export function SystemConfigs() { + const { + systemConfigs: configs, + systemConfigsLoading: loading, + systemConfigsError: error, + refetchSystemConfigs, + updateSystemConfig, + deleteSystemConfig + } = useAdmin() + + const getInitialEditData = (config: SystemConfig) => ({ + value: config.value, + description: config.description + }) + + const handleSave = async (config: SystemConfig, editData: Partial) => { + if (!config) return + + await updateSystemConfig(config.key, { + value: editData.value ?? config.value, + description: editData.description ?? config.description + }) + await refetchSystemConfigs() + } + + const handleDelete = async (config: SystemConfig) => { + await deleteSystemConfig(config.key) + } + + return ( + + title="系统配置" + data={configs} + loading={loading} + error={error} + onReload={refetchSystemConfigs} + getInitialEditData={getInitialEditData} + onSave={handleSave} + onDelete={handleDelete} + getId={(config) => config.key} + emptyDescription="未发现系统配置" + loadingDescription="配置加载中" + columns={[ + { header: "配置键", cell: (item) => {item.key}, width: "200px" }, + { header: "配置值", cell: (item) => {item.value}, width: "120px" }, + { header: "描述", cell: (item) => {item.description}, width: "200px" }, + ]} + renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => ( + + )} + /> + ) +} diff --git a/frontend/components/common/admin/tasks.tsx b/frontend/components/common/admin/tasks.tsx new file mode 100644 index 00000000..da1b79a0 --- /dev/null +++ b/frontend/components/common/admin/tasks.tsx @@ -0,0 +1,370 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Spinner } from "@/components/ui/spinner" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Layers, Play, Clock, Info } from "lucide-react" + +import { TaskMeta, AdminService, DispatchTaskRequest } from "@/lib/services" +import { ErrorInline } from "@/components/layout/error" +import { LoadingStateWithBorder } from "@/components/layout/loading" +import { EmptyStateWithBorder } from "@/components/layout/empty" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +import { format } from "date-fns" +import { zhCN } from "date-fns/locale" +import { Calendar as CalendarIcon } from "lucide-react" +import { Calendar } from "@/components/ui/calendar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +const TASK_CONFIGS: Record, color: string, gradient: string }> = { + 'order_sync': { + icon: Layers, + color: "text-blue-600 dark:text-blue-400", + gradient: "from-blue-500/10 via-blue-500/5 to-transparent border-blue-200/50 dark:border-blue-800/50 hover:border-blue-400 dark:hover:border-blue-500", + }, + 'user_gamification': { + icon: Play, + color: "text-amber-600 dark:text-amber-400", + gradient: "from-amber-500/10 via-amber-500/5 to-transparent border-amber-200/50 dark:border-amber-800/50 hover:border-amber-400 dark:hover:border-amber-500", + }, + 'dispute_auto_refund': { + icon: Clock, + color: "text-rose-600 dark:text-rose-400", + gradient: "from-rose-500/10 via-rose-500/5 to-transparent border-rose-200/50 dark:border-rose-800/50 hover:border-rose-400 dark:hover:border-rose-500", + } +} + +const DEFAULT_TASK_CONFIG = { + icon: Layers, + color: "text-zinc-600 dark:text-zinc-400", + gradient: "from-zinc-500/10 via-zinc-500/5 to-transparent border-zinc-200/50 dark:border-zinc-800/50 hover:border-zinc-400 dark:hover:border-zinc-500", +} + +function DatePickerWithTime({ date, setDate }: { date: Date | undefined, setDate: (date: Date | undefined) => void }) { + const timeString = date ? format(date, "HH:mm:ss") : "" + + const handleDateSelect = (newDate: Date | undefined) => { + if (!newDate) { + setDate(undefined) + return + } + + const updated = new Date(newDate) + + if (date) { + updated.setHours(date.getHours()) + updated.setMinutes(date.getMinutes()) + updated.setSeconds(date.getSeconds()) + } else { + updated.setHours(0, 0, 0, 0) + } + + setDate(updated) + } + + const handleTimeChange = (e: React.ChangeEvent) => { + const newTime = e.target.value + if (!newTime) return + + if (date) { + const [hours, minutes, seconds] = newTime.split(':').map(Number) + const updated = new Date(date) + updated.setHours(hours || 0) + updated.setMinutes(minutes || 0) + updated.setSeconds(seconds || 0) + setDate(updated) + } else { + const today = new Date() + const [hours, minutes, seconds] = newTime.split(':').map(Number) + today.setHours(hours || 0) + today.setMinutes(minutes || 0) + today.setSeconds(seconds || 0) + setDate(today) + } + } + + return ( +
+
+ + + + + + + + +
+
+ +
+
+ ) +} + +export function TaskManager() { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [taskTypes, setTaskTypes] = useState([]) + + const [dispatching, setDispatching] = useState(false) + const [selectedTaskType, setSelectedTaskType] = useState(null) + const [dialogOpen, setDialogOpen] = useState(false) + + const [startTime, setStartTime] = useState(undefined) + const [endTime, setEndTime] = useState(undefined) + const [userId, setUserId] = useState("") + + const fetchTaskTypes = useCallback(async () => { + try { + setLoading(true) + setError(null) + const data = await AdminService.getTaskTypes() + setTaskTypes(data) + } catch (err) { + setError(err instanceof Error ? err : new Error('加载任务类型失败')) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchTaskTypes() + }, [fetchTaskTypes]) + + const handleDispatch = async () => { + if (!selectedTaskType) return + + try { + setDispatching(true) + + const targetTask = taskTypes.find(t => t.type === selectedTaskType) + + const params: DispatchTaskRequest = { + task_type: selectedTaskType + } + + if (targetTask?.type === 'order_sync') { + if (startTime) params.start_time = startTime.toISOString() + if (endTime) params.end_time = endTime.toISOString() + } + + if (targetTask?.type === 'user_gamification') { + if (userId) params.user_id = userId + } + + await AdminService.dispatchTask(params) + + toast.success('任务下发成功', { + description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列` + }) + setDialogOpen(false) + + setStartTime(undefined) + setEndTime(undefined) + setUserId("") + } catch (err) { + toast.error('任务下发失败', { + description: err instanceof Error ? err.message : '未知错误' + }) + } finally { + setDispatching(false) + } + } + + const openDispatchDialog = (type: string) => { + setSelectedTaskType(type) + setDialogOpen(true) + } + + const getSelectedTaskMeta = () => { + return taskTypes.find(t => t.type === selectedTaskType) + } + + return ( +
+
+
+
任务管理
+
+
+ +
+ {error ? ( +
+ +
+ ) : loading && taskTypes.length === 0 ? ( + + ) : taskTypes.length === 0 ? ( + + ) : ( +
+ {taskTypes.map((task, index) => { + const config = TASK_CONFIGS[task.type] || DEFAULT_TASK_CONFIG + + return ( +
+
+
+
+
+

{task.name}

+

+ {task.description} +

+
+ + {task.queue} + +
+ +
+ + 类型:{task.type} + + + 重试:{task.max_retry} + +
+
+ +
+ +
+
+
+ ) + })} +
+ )} +
+ + + + + 下发任务 + + 配置任务参数并启动,任务将在后台异步执行。 + + + +
+
+ +
+ {(() => { + const meta = getSelectedTaskMeta() + if (!meta) return null + const config = TASK_CONFIGS[meta.type] || DEFAULT_TASK_CONFIG + const Icon = config.icon + return ( + <> +
+ +
+
+ {meta.name} + {meta.type} +
+ + ) + })()} +
+
+ + {selectedTaskType === 'order_sync' && ( + <> +
+ + +

选择同步的起始时间点

+
+
+ + +

选择同步的结束时间点

+
+ + )} + + {selectedTaskType === 'user_gamification' && ( +
+ + setUserId(e.target.value)} + /> +

指定需要更新积分数据的用户 ID

+
+ )} + + {!['order_sync', 'user_gamification'].includes(selectedTaskType || '') && ( +
+ + 此任务无需额外配置参数,确认后将直接下发。 +
+ )} +
+ + + + + +
+
+
+ ) +} diff --git a/frontend/components/common/admin/users.tsx b/frontend/components/common/admin/users.tsx new file mode 100644 index 00000000..478748a0 --- /dev/null +++ b/frontend/components/common/admin/users.tsx @@ -0,0 +1,598 @@ +"use client" + +import { useState, useEffect } from "react" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { Separator } from "@/components/ui/separator" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Sheet, SheetTitle, SheetContent } from "@/components/ui/sheet" +import { Badge } from "@/components/ui/badge" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Layers, Search, UserX, UserCheck, Eye, Wallet, CreditCard, ShieldCheck, Filter, X, ChevronDown, ChevronLeft, ChevronRight, Users, Loader2 } from "lucide-react" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" + +import { AdminUser, AdminService, DispatchTaskRequest } from "@/lib/services" +import { toast } from "sonner" +import { formatDateTime } from "@/lib/utils" +import { EmptyStateWithBorder } from "@/components/layout/empty" +import { LoadingStateWithBorder } from "@/components/layout/loading" +import { ErrorInline } from "@/components/layout/error" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { cn } from "@/lib/utils" +import { useAdminUsers } from "@/contexts/admin-users-context" + +export function UsersManager() { + const { + users, + total, + loading, + error, + page, + pageSize, + searchUserId, + searchUsername, + statusFilter, + setPage, + setPageSize, + setSearchUserId, + setSearchUsername, + setStatusFilter, + fetchUsers, + updateUserStatus + } = useAdminUsers() + + const [selectedUser, setSelectedUser] = useState(null) + const [detailOpen, setDetailOpen] = useState(false) + const [updatingUserId, setUpdatingUserId] = useState(null) + + useEffect(() => { + fetchUsers() + }, [fetchUsers]) + + const handleStatusToggle = async (user: AdminUser) => { + await updateUserStatus(user) + + if (selectedUser?.id === user.id) { + setSelectedUser(prev => prev ? { ...prev, is_active: !prev.is_active } : null) + } + } + + const handleShowDetail = (user: AdminUser) => { + setSelectedUser(user) + setDetailOpen(true) + } + + const handleUpdateCredits = async (user: AdminUser) => { + try { + setUpdatingUserId(user.id) + const params: DispatchTaskRequest = { + task_type: 'user_gamification', + user_id: user.id + } + await AdminService.dispatchTask(params) + toast.success('积分更新任务已下发', { + description: `正在更新用户 ${ user.username } 的积分数据` + }) + } catch (err) { + toast.error('任务下发失败', { + description: err instanceof Error ? err.message : '未知错误' + }) + } finally { + setUpdatingUserId(null) + } + } + + const totalPages = Math.ceil(total / pageSize) + const hasSearchFilter = Boolean(searchUserId || searchUsername) + + const renderFilterBar = () => ( +
+
+ + + + + +
+ setSearchUserId(e.target.value)} + /> + setSearchUsername(e.target.value)} + /> + {hasSearchFilter && ( + + )} +
+
+
+ + + + + + + { e.preventDefault(); setStatusFilter('all') }} + > +
+ 全部状态 + + { e.preventDefault(); setStatusFilter('active') }} + > +
+ 正常 + + { e.preventDefault(); setStatusFilter('inactive') }} + > +
+ 禁用 + + + + + {(hasSearchFilter || statusFilter !== 'all') && ( + <> + + + + )} +
+ + + +
+ + {total} 条记录 + +
+ + + {page}/{totalPages} + + +
+ + + + + + + {[20, 50, 100].map(size => ( + setPageSize(size)} + className={cn("font-mono text-xs", pageSize === size && "bg-accent")} + > + {size}条/页 + + ))} + + + + +
+
+ ) + + return ( +
+
+
+
用户管理
+
+
+ + {renderFilterBar()} + + {error ? ( +
+ fetchUsers(true)} className="justify-center" /> +
+ ) : loading && users.length === 0 ? ( + + ) : users.length === 0 ? ( + + ) : ( +
+ + + + ID + 用户 + 余额 + 基准分 + 支付分 + 总收益 + 总消耗 + 总划转 + 上次登陆 + 注册时间 + 上次更新 + 操作 + + + + {users.map((user) => ( + handleShowDetail(user)} + > + {user.id} + +
+ + + + {user.username.substring(0, 2).toUpperCase()} + + +
+
+ {user.nickname} + {user.is_admin && ( + + ADM + + )} +
+
+ @{user.username} +
+
+
+
+ + {Number(user.available_balance).toFixed(2)} + + + {Number(user.community_balance).toFixed(2)} + + + {Number(user.pay_score).toFixed(2)} + + + {Number(user.total_receive).toFixed(2)} + + + {Number(user.total_payment).toFixed(2)} + + + {Number(user.total_community).toFixed(2)} + + + {formatDateTime(user.last_login_at)} + + + {formatDateTime(user.created_at)} + + + {formatDateTime(user.updated_at)} + + e.stopPropagation()}> +
+ + + +
+ handleStatusToggle(user)} + disabled={user.is_admin} + className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7" + /> +
+
+ + {user.is_admin ? '管理员账户' : user.is_active ? '禁用账户' : '启用账户'} + +
+
+ + + + + + + 更新积分 + + + + + + + + + + 查看详情 + + + +
+
+
+ ))} +
+
+
+ )} + + + + 用户档案 + + {selectedUser && ( + <> +
+
+
+
+ + + + {selectedUser.username.substring(0, 2).toUpperCase()} + + + +
+

{selectedUser.nickname}

+
+ @{selectedUser.username} + + UID: {selectedUser.id} + + + Lv.{selectedUser.trust_level} + + {selectedUser.is_admin && ( + + Admin + + )} +
+
+ +
+
+ 注册时间 + {formatDateTime(selectedUser.created_at).split(' ')[0]} +
+
+
+
+ +
+

核心资产

+
+
+
+ + 可用余额 +
+
+ {Number(selectedUser.available_balance).toFixed(2)} +
+
+
+
+ + 支付积分 +
+
+ {Number(selectedUser.pay_score).toFixed(2)} +
+
+
+
+ + 社区余额 +
+
+ {Number(selectedUser.community_balance).toFixed(2)} +
+
+
+
+ + + +
+
+

财务概况

+
+
+ 累计收益 + +{Number(selectedUser.total_community).toFixed(2)} +
+
+ 累计收入 + +{Number(selectedUser.total_receive).toFixed(2)} +
+
+ 累计支出 + {Number(selectedUser.total_payment) === 0 ? '0.00' : `-${ Number(selectedUser.total_payment).toFixed(2) }`} +
+
+
+ +
+

系统记录

+
+
+ 最后登录 + {formatDateTime(selectedUser.last_login_at)} +
+
+ 注册时间 + {formatDateTime(selectedUser.created_at)} +
+
+ 最后更新 + {formatDateTime(selectedUser.updated_at)} +
+
+
+
+ +
+
+ + {!selectedUser.is_admin && ( +
+ +
+ )} + + )} +
+
+
+ ) +} diff --git a/frontend/components/common/balance/balance-main.tsx b/frontend/components/common/balance/balance-main.tsx new file mode 100644 index 00000000..be2fa391 --- /dev/null +++ b/frontend/components/common/balance/balance-main.tsx @@ -0,0 +1,54 @@ +"use client" + +import * as React from "react" +import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number" +import { BalanceReport } from "@/components/common/balance/balance-report" +import { BalanceSummary } from "@/components/common/balance/balance-summary" +import { BalanceTable } from "@/components/common/balance/balance-table" +import { useUser } from "@/contexts/user-context" + +/** + * 积分余额主页面组件 + * + * 负责组装积分余额页面的各个子组件,包括积分余额总览、积分余额摘要、近期活动和报告侧边栏 + */ +export function BalanceMain() { + const { user, loading } = useUser() + + /** 计算总余额 */ + const totalBalance = React.useMemo(() => { + return parseFloat(user?.available_balance || '0') + }, [user?.available_balance]) + + return ( +
+
+

+ 积分 + LDC + + {loading ? "-" : } + +

+
+ +
+
+
+

积分摘要

+ +
+ +
+

近期活动

+ +
+
+ + +
+
+ ) +} diff --git a/frontend/components/common/balance/balance-report.tsx b/frontend/components/common/balance/balance-report.tsx new file mode 100644 index 00000000..e502f084 --- /dev/null +++ b/frontend/components/common/balance/balance-report.tsx @@ -0,0 +1,15 @@ +import * as React from "react" + +/** + * 余额报告组件 + * + * 显示可下载或查看的报告列表 + */ +export function BalanceReport() { + return ( +
+

报告

+
暂无报告
+
+ ) +} diff --git a/frontend/components/common/balance/balance-summary.tsx b/frontend/components/common/balance/balance-summary.tsx new file mode 100644 index 00000000..89076372 --- /dev/null +++ b/frontend/components/common/balance/balance-summary.tsx @@ -0,0 +1,117 @@ +import * as React from "react" +import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number" +import { useUser } from "@/contexts/user-context" + +/** 颜色配置 - 统一管理主题色 */ +const COLORS = { + available: "bg-primary/80", + pending: "bg-zinc-400/80", + emptyState: "bg-muted/50", +} as const + +/** + * 计算积分余额百分比 + */ +function calculatePercentages(available: number, total: number) { + if (total <= 0) { + return { available: 0, pending: 0 } + } + + const pending = total - available + return { + available: (available / total) * 100, + pending: (pending / total) * 100, + } +} + +/** + * 安全转换数字,避免 NaN + */ +function safeNumber(value: number): number { + return typeof value === 'number' && !isNaN(value) ? value : 0 +} + +/** + * 积分余额摘要组件 + * + * 显示积分余额的可视化摘要,包括可视化进度条和详细的积分余额分类列表 + */ +export function BalanceSummary() { + const { user, loading } = useUser() + + const available = parseFloat(user?.available_balance || '0') + const pending = parseFloat(user?.pending_balance || '0') + const total = available + pending + + const percentages = React.useMemo( + () => calculatePercentages(available, total), + [available, total] + ) + + return ( +
+
+
+
+
+ +
+
+ 积分类型 + 值 +
+ +
+
+ + + {loading ? "-" : ( + + )} LDC + +
+ +
+
+ + + {loading ? "-" : ( + + )} LDC + +
+
+
+ ) +} diff --git a/frontend/components/common/balance/balance-table.tsx b/frontend/components/common/balance/balance-table.tsx new file mode 100644 index 00000000..a33e358f --- /dev/null +++ b/frontend/components/common/balance/balance-table.tsx @@ -0,0 +1,150 @@ +"use client" + +import * as React from "react" +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { TransactionTableList } from "@/components/common/general/table-data" +import { TransactionProvider, useTransaction } from "@/contexts/transaction-context" +import { DEFAULT_ORDER_TYPES, type OrderType } from "@/lib/services" +import { formatLocalDate } from "@/lib/utils" + +/** 标签触发器样式 */ +const TAB_TRIGGER_STYLES = + "data-[state=active]:bg-transparent " + + "data-[state=active]:shadow-none " + + "data-[state=active]:border-0 " + + "data-[state=active]:border-b-2 " + + "data-[state=active]:border-indigo-500 " + + "bg-transparent " + + "rounded-none " + + "border-0 " + + "border-b-2 " + + "border-transparent " + + "px-0 " + + "text-sm " + + "font-bold " + + "text-muted-foreground " + + "data-[state=active]:text-indigo-500 " + + "-mb-[2px] " + + "relative " + + "hover:text-foreground " + + "transition-colors " + + "flex-none" + +/** 标签配置 - 数据驱动渲染 */ +const TABS = [ + { value: "receive" as const, label: "积分收益" }, + { value: "payment" as const, label: "积分消耗" }, + { value: "community" as const, label: "社区划转" }, + { value: "online" as const, label: "在线流转" }, + { value: "all" as const, label: "所有活动" }, +] as const + +/** + * 计算最近30天的时间范围 + * 每次调用时重新计算,避免缓存过期时间 + */ +function getTimeRange() { + const now = new Date() + now.setHours(23, 59, 59, 999) + const start = new Date(now) + start.setDate(start.getDate() - 30) + start.setHours(0, 0, 0, 0) + return { + startTime: formatLocalDate(start), + endTime: formatLocalDate(now) + } +} + +/** + * 积分余额活动表格组件 + * + * 显示不同类型的积分余额活动记录,支持多标签切换和分页加载 + */ +export function BalanceTable() { + const [activeTab, setActiveTab] = React.useState("all") + const [timeRange, setTimeRange] = React.useState<{ startTime: string; endTime: string } | null>(null) + + React.useEffect(() => { + setTimeRange(getTimeRange()) + }, []) + + if (!timeRange) { + return null + } + + return ( +
+ setActiveTab(value as OrderType | "all")} + className="w-full" + > + + {TABS.map((tab) => ( + + {tab.label} + + ))} + + +
+ + + +
+
+
+ ) +} + +/** + * 积分余额活动列表组件 + * + * 负责获取和显示积分余额活动数据 + */ +const TransactionList = React.memo(function TransactionList({ type }: { type?: OrderType }) { + const { + transactions, + loading, + error, + lastParams, + fetchTransactions, + } = useTransaction() + + /** 当积分余额活动类型变化时重新加载数据 */ + React.useEffect(() => { + fetchTransactions({ + page: 1, + types: type ? [type] : DEFAULT_ORDER_TYPES, + startTime: lastParams.startTime, + endTime: lastParams.endTime, + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [type]) + + return ( + fetchTransactions({ + page: 1, + types: type ? [type] : DEFAULT_ORDER_TYPES, + startTime: lastParams.startTime, + endTime: lastParams.endTime, + })} + /> + ) +}) diff --git a/frontend/components/common/docs/api.tsx b/frontend/components/common/docs/api.tsx new file mode 100644 index 00000000..f14b39c3 --- /dev/null +++ b/frontend/components/common/docs/api.tsx @@ -0,0 +1,589 @@ +import { type PolicySection } from "./types" +import { CodeBlock } from "@/components/ui/code-block" +import { + DocsTable, + DocsTableHeader, + DocsTableBody, + DocsTableHead, + DocsTableRow, + DocsTableCell, +} from "@/components/ui/docs-table" + +export const DOCS_LAST_UPDATED = "2026-04-20" + +/** + * ------------------------------------------------------------------ + * API 文档 + * ------------------------------------------------------------------ + */ +export const apiSections: PolicySection[] = [ + { + value: "official-service", + title: "1. 官方 LDC 接口", + content: ( +
+
+

官方原生接口,使用 Ed25519 签名算法,安全性更高

+
+ +

1.1 概览

+
    +
  • 协议:官方 LDC 支付协议
  • +
  • 服务类型:支持 type=ldcpay
  • +
  • 网关基址:https://credit.linux.do/epay
  • +
  • 签名方式:Ed25519 非对称加密
  • +
+ +

1.2 对接流程

+
    +
  1. 控制台创建应用,配置 client_id 并在应用设置中上传商户 Ed25519 公钥
  2. +
  3. 根据“签名算法”及商户私钥生成 sign
  4. +
  5. 调用 /pay/submit 发起积分流转请求
  6. +
  7. 认证完成后,通过异步回调或轮询接口同步状态
  8. +
+ +

1.3 鉴权与签名

+

1.3.1 签名算法

+
+
    +
  1. 取除 sign 以外的所有非空请求参数
  2. +
  3. 将参数按参数名 ASCII 码从到大排序(字典序)
  4. +
  5. 使用 k1=v1&k2=v2... 格式拼接成字符串
  6. +
  7. 将 应用密钥 (Client Secret) 直接追加到字符串末尾
  8. +
  9. 使用商户私钥对最终字符串进行 Ed25519 签名
  10. +
  11. 将签名结果转换成 Base64 编码作为 sign 参数
  12. +
+ +
+ +

1.4 积分流转服务

+
    +
  • 方法:POST /epay/pay/submit.php
  • +
  • 编码:application/json 或 application/x-www-form-urlencoded
  • +
+ + + + + 参数 + 必填 + 说明 + + + + + client_id + 是 + 应用客户端 ID + + + type + 是 + 固定 ldcpay + + + out_trade_no + 是 + 业务单号 + + + money + 是 + 积分数量,必须保留两位小数(比如,10.00) + + + order_name + 是 + 商品名称 + + + notify_url + 否 + 会参与签名;可选订单级异步通知地址。长度不超过 100,需为合法 URL。传入后支付成功优先回调该地址,未传则使用应用 notify_url + + + return_url + 否 + 会参与签名;可选订单级回跳地址。长度不超过 100,需为合法 URL。传入后支付成功页面优先跳转该地址,未传则使用应用 redirect_uri + + + sign + 是 + 按“签名算法”生成的 Base64 签名串 + + + + +

1.5 其他接口

+

其他接口定义请参考 3. 其他接口。

+ +
+ ), + children: [ + { value: "1-1-overview", title: "1.1 概览" }, + { value: "1-2-flow", title: "1.2 对接流程" }, + { value: "1-3-auth-sign", title: "1.3 鉴权与签名" }, + { value: "1-4-submit", title: "1.4 积分流转服务" }, + { value: "1-5-others", title: "1.5 其他接口" }, + ] + }, + { + value: "epay-compatibility", + title: "2. 易支付兼容接口", + content: ( +
+
+

兼容易支付、CodePay、VPay 等支付协议

+
+ +

2.1 概览

+
    +
  • 协议:EasyPay / CodePay / VPay 兼容协议
  • +
  • 服务类型:仅支持 type=epay
  • +
  • 网关基址:https://credit.linux.do/epay
  • +
  • 订单有效期:取系统配置 merchant_order_expire_minutes(平台端设置)
  • +
+ +

2.2 常见错误

+
    +
  • 不支持的请求类型:type 仅允许 epay
  • +
  • 签名验证失败:参与签名字段与请求体需一致,密钥直接拼接
  • +
  • 金额必须大于0 / 积分小数位数不能超过2位
  • +
  • 订单已过期:超出系统配置有效期
  • +
  • 订单不存在或已完成:订单号错误、已退回或已完成
  • +
  • 余额不足:余额退回时用户积分不足
  • +
+ +

2.3 对接流程

+
    +
  1. 控制台创建 API Key,记录 pid、key,配置回调地址
  2. +
  3. 按“签名算法”生成 sign,调用 /epay/pay/submit.php 创建积分流转服务并跳转认证界面
  4. +
  5. 可通过 /epay/api.php 轮询结果,或等待异步回调
  6. +
  7. 退回服务时,携带同一 trade_no 和原积分数量,调用积分退回接口
  8. +
  9. 回调验签通过后返回 success 完成闭环
  10. +
+ +

2.4 鉴权与签名

+

2.4.1 API Key

+
    +
  • pid:Client ID
  • +
  • key:Client Secret(妥善保管)
  • +
  • notify_url / return_url:应用级默认回调地址(兜底);创建订单时可在请求中传同名字段作为订单级覆盖,未传时回退应用配置。
  • +
+ +

2.4.2 签名算法

+
+
    +
  1. 取所有非空字段(排除 sign、sign_type 字段)
  2. +
  3. 将上述字段按 ASCII 升序,依次拼成 k1=v1&k2=v2
  4. +
  5. 在末尾追加应用密钥:k1=v1&k2=v2{"{secret}"}
  6. +
  7. 整体进行 MD5,取小写十六进制作为 sign
  8. +
+ +
+ +

2.5 积分流转服务

+
    +
  • 方法:POST /epay/pay/submit.php
  • +
  • 编码:application/json 或 application/x-www-form-urlencoded
  • +
  • 成功:验签通过后,平台自动创建积分流转服务,并跳转到认证界面(Location=https://credit.linux.do/paying?order_no=...)
  • +
  • 失败:返回 JSON {`{"error_msg":"...", "data":null}`}
  • +
+ + + + + 参数 + 必填 + 说明 + + + + + pid + 是 + Client ID + + + type + 是 + 固定 epay + + + out_trade_no + 否 + 业务单号,建议全局唯一 + + + name + 是 + 标题,最多 64 字符 + + + money + 是 + 积分数量,最多 2 位小数 + + + notify_url + 否 + 会参与签名;可选订单级异步通知地址。长度不超过 100,需为合法 URL。传入后支付成功优先回调该地址,未传则使用应用 notify_url + + + return_url + 否 + 会参与签名;可选订单级回跳地址。长度不超过 100,需为合法 URL。传入后支付成功页面优先跳转该地址,未传则使用应用 redirect_uri + + + device + 否 + 终端标识,可选 + + + sign + 是 + 按“签名算法”生成 + + + sign_type + 否 + 固定 MD5 + + + + +

请求示例:

+ + +

2.6 其他接口

+

其他接口定义请参考 3. 其他接口。

+ +
+ ), + children: [ + { value: "2-1-overview", title: "2.1 概览" }, + { value: "2-2-common-errors", title: "2.2 常见错误" }, + { value: "2-3-flow", title: "2.3 对接流程" }, + { value: "2-4-auth-sign", title: "2.4 鉴权与签名" }, + { value: "2-5-submit", title: "2.5 积分流转服务" }, + { value: "2-6-others", title: "2.6 其他接口" }, + ] + }, + { + value: "common-services", + title: "3. 其他接口", + content: ( +
+
+

官方接口与易支付兼容接口公用接口。

+
+ +

3.1 订单查询

+
    +
  • 方法:GET /epay/api.php
  • +
  • 认证:pid + key
  • +
  • 说明:out_trade_no 必填;act 可传 order,后端不强校验。
  • +
+ + + + + 参数 + 必填 + 说明 + + + + + act + 否 + 可选字段,建议 order + + + pid + 是 + Client ID + + + key + 是 + Client Secret + + + out_trade_no + 是 + 业务单号 + + + + +

成功响应:

+ +

补充:status 1=成功,0=失败/处理中;不存在会返回 HTTP 404 且 {`{"code":-1,"msg":"服务不存在或已完成"}`}。

+ +

3.2 订单退款

+
    +
  • 方法:POST /epay/api.php
  • +
  • 编码:application/json 或 application/x-www-form-urlencoded
  • +
  • 限制:仅支持对已成功的积分流转服务进行积分的全额退回
  • +
+ +
+ + + + 参数 + 必填 + 说明 + + + + + pid + 是 + Client ID + + + key + 是 + Client Secret + + + trade_no + 是 + 编号 + + + money + 是 + 必须等于原积分流转服务的积分数量 + + + out_trade_no + 否 + 业务单号(兼容) + + + +
+ +

响应:

+ +

常见失败:服务不存在/未认证、金额不合法(<=0 或小数超过 2 位)。

+ + +

3.3 异步通知

+
    +
  • 触发:认证成功后;失败自动重试,最多 5 次(单次 30s 超时)
  • +
  • 目标:订单级 notify_url(如有)优先,否则回退到创建应用时设置的 notify_url
  • +
  • 方式:HTTP GET
  • +
+ +
+ + + + 参数 + 说明 + + + + + pid + Client ID + + + trade_no + 编号 + + + out_trade_no + 业务单号 + + + type + 固定 epay + + + name + 标题 + + + money + 积分数量,最多 2 位小数 + + + trade_status + 固定 TRADE_SUCCESS + + + sign + 按“签名算法”生成 + + + +
+

应用需返回 HTTP 200 且响应体为 success(大小写不敏感),否则视为失败并继续重试。

+ +

3.4 商户分发接口

+
    +
  • 方法:POST /lpay/distribute
  • +
  • 编码:application/json
  • +
  • 认证:Basic Auth (使用 client_id:client_secret 进行 Base64 编码)
  • +
+ + + + + 参数 + 必填 + 说明 + + + + + user_id + 是 + 收款人用户 ID (数字) + + + username + 是 + 收款人用户名 (用于二次校验) + + + amount + 是 + 分发积分数量,最多 2 位小数 + + + out_trade_no + 否 + 商户自定义单号 + + + remark + 否 + 分发备注 + + + +

成功响应:{`{"code":1, "data":{"trade_no":"...", "out_trade_no":"..."}}`}

+ +

3.5 用户余额统计

+
    +
  • 方法:GET /api/v1/dashboard/stats/user-balance
  • +
  • 认证:无需鉴权(公开接口)
  • +
  • 说明:获取平台所有用户可用余额的统计数据,结果有缓存,TTL 由系统配置决定
  • +
+ +

成功响应:

+ + + + + + 字段 + 说明 + + + + + total_count + 统计用户总数 + + + total_amount + 所有用户可用余额之和 + + + avg_amount + 平均余额 + + + median_amount + 余额中位数 + + + min_amount + 最小余额 + + + max_amount + 最大余额 + + + std_dev + 余额标准差 + + + +
+ ), + children: [ + { value: "3-1-order", title: "3.1 订单查询" }, + { value: "3-2-refund", title: "3.2 订单退款" }, + { value: "3-3-notify", title: "3.3 异步通知" }, + { value: "3-4-distribute", title: "3.4 商户分发接口" }, + { value: "3-5-user-balance", title: "3.5 用户余额统计" }, + ] + }, +] diff --git a/frontend/components/common/docs/how-to-use.tsx b/frontend/components/common/docs/how-to-use.tsx new file mode 100644 index 00000000..c60006e0 --- /dev/null +++ b/frontend/components/common/docs/how-to-use.tsx @@ -0,0 +1,329 @@ +import { type PolicySection } from "./types" +import { CodeBlock } from "@/components/ui/code-block" +import { + DocsTable, + DocsTableHeader, + DocsTableBody, + DocsTableHead, + DocsTableRow, + DocsTableCell, +} from "@/components/ui/docs-table" + +/** + * ------------------------------------------------------------------ + * 使用指南 (How To Use) + * ------------------------------------------------------------------ + */ +export const howToUseSections: PolicySection[] = [ + { + value: "quick-start", + title: "1. 快速开始", + content: ( +
+
+

为社区开发者与用户提供完整的平台使用说明

+
+
    +
  • 身份认证:基于 LINUX DO Connect (OAuth)
  • +
  • 认证方式:账户积分消耗认证
  • +
  • 手续费:动态费率,由服务方承担
  • +
  • 争议处理:支持服务方与消费方的双方争议处理
  • +
+
+ ) + }, + { + value: "roles", + title: "2. 角色说明", + content: ( +
+
    +
  • 服务方:最终积分流转的转入方
  • +
  • 消费方:最终积分流转的转出方
  • +
  • 认证平台:LINUX DO Credit 系统本身
  • +
+
+ ) + }, + { + value: "integration", + title: "3. 接入积分服务", + content: ( +
+

3.1 使用 API 接口

+
    +
  1. + 创建应用 +
      +
    • 前往 集市中心
    • +
    • 点击顶部右侧 创建应用 按钮
    • +
    • 填写必要信息:应用名称、应用主页、回调地址、通知地址
    • +
    +
  2. +
  3. + 获取 API 凭证 +
      +
    • 在集市中心顶部右侧选择器中选择您的应用
    • +
    • 在 API 配置 面板中获取: +
        +
      • Client ID:客户端ID,用于标识您的身份
      • +
      • Client Secret:客户端密钥,用于签名验证(请妥善保管,切勿泄露)
      • +
      +
    • +
    +
  4. +
  5. + 使用 API 接口 +
      +
    • 使用 API 接口创建积分流转服务
    • +
    • 参考文档:API 接口文档
    • +
    +
  6. +
+ +

3.2 使用在线服务

+
    +
  • 适用场景:无代码开发基础,或只用于简单的积分服务。
  • +
  • 操作步骤: +
      +
    1. 前往 集市中心 创建应用,获取 API 凭证
    2. +
    3. 选择应用,点击 在线收款 功能
    4. +
    5. 创建在线积分服务
    6. +
    7. 获取唯一积分服务链接
    8. +
    9. 发送给您所服务的客户使用
    10. +
    +
  • +
+ +

3.3 快速集成 New API

+
    +
  • 适用场景:New API 站点,LINUX DO Credit 兼容 EasyPay 协议,公益站站长可直接集成。
  • +
  • 操作步骤: +
      +
    1. + 前往 集市中心,点击 创建应用,填写 New API 站点信息: +
      + + + + 字段 + 值 + + + + + 应用名称 + 您的应用名称 + + + 应用主页 + https://{"{您的 New API 域名}"} + + + 回调地址 + https://{"{您的 New API 域名}"}/console/log + + + 通知地址 + https://{"{您的 New API 域名}"}/api/user/epay/notify + + + +
      +
    2. +
    3. 前往 New API 站点的系统设置,找到 支付设置。
    4. +
    5. + 配置 LINUX DO Credit 平台参数: +
      + + + + 参数 + 值 + + + + + 支付地址 + https://credit.linux.do/epay/pay + + + 易支付商户ID + 您的 Client ID + + + 易支付商户密钥 + 您的 Client Secret + + + 回调地址 + https://{"{您的 New API 域名}"} + + + +
      +
    6. +
    7. + 配置充值方式(JSON 格式): +
      + +
      +
    8. +
    +
  • +
+
+ ), + children: [ + { value: "3-1-api", title: "3.1 使用 API 接口" }, + { value: "3-2-online", title: "3.2 使用在线服务" }, + { value: "3-3-new-api", title: "3.3 快速集成 New API" }, + ] + }, + { + value: "usage", + title: "4. 使用 LINUX DO Credit 积分", + content: ( +
+

您可以在任意支持 LINUX DO Credit 的平台下使用积分。在其他平台点击使用 LINUX DO Credit 积分时,会自动跳转到 LINUX DO Credit 的积分流转服务页面,您只需要确认积分流转信息无误,并选择使用 LINUX DO Credit 进行账户认证,即可完成整个交易服务。

+
+ ) + }, + { + value: "fees", + title: "5. 服务(手续)费", + content: ( +
+

5.1 规则说明

+

为了更好的维持 LINUX DO Credit 平台的积分服务机制,保证社区积分的生态可持续发展,我们会按照规范进行不同程度的服务(手续)费用收取。

+
    +
  • 承担方:服务(手续)费默认由服务方承担
  • +
  • 消费方使用:不会产生额外费用
  • +
  • 服务方实收:订单金额 - 服务(手续)费
  • +
+
+

计算公式:

+ +
+ +

5.2 动态费率

+

费率并非固定不变,会根据以下因素动态调整:

+
    +
  • 服务方平台等级
  • +
  • 服务方平台积分
  • +
  • LINUX DO Credit 平台活动
  • +
+
+ ), + children: [ + { value: "5-1-rules", title: "5.1 规则说明" }, + { value: "5-2-dynamic", title: "5.2 动态费率" }, + ] + }, + { + value: "dispute", + title: "6. 争议处理", + content: ( +
+

为了保障服务方与消费方的合法权益,当积分服务出现纠纷时,可使用争议功能。

+
    +
  • 作为服务方,您需要及时响应消费方的争议请求: +
      +
    1. 在集市中心或通知中查看到 待处理的争议
    2. +
    3. 查看消费方理由,选择操作: +
        +
      • 同意:认可消费方诉求,积分原路退回给消费方
      • +
      • 拒绝:如果您认为已履约,请提交相关证据
      • +
      +
    4. +
    +
  • +
+
+

+ 重要:建议服务方与消费方优先沟通解决。长时间未处理的争议会由 LINUX DO Credit 平台介入仲裁,这可能会影响您的服务方信誉。 +

+
+
+ ) + }, + { + value: "community-balance", + title: "7. 社区积分", + content: ( +
+

您的 LINUX DO Credit 平台基础积分主要由 社区积分 (Community Balance) 划转而来。

+
    +
  • 基本获取方式:通过在 LINUX DO 社区的活跃行为获得: + +
  • +
  • 划转规则: +
      +
    • 划转时间:社区积分每日凌晨 00:00 自动划转至可用余额
    • +
    • 限制说明:划转前不可用于任何积分服务
    • +
    • 服务费用:目前不收取任何划转 服务费
    • +
    +
  • +
+
+ ) + }, + { + value: "settings", + title: "8. 账户设置", + content: ( +
+

您可以在 设置 (Settings) 页面管理您的账户信息。

+

功能列表

+
    +
  • 个人资料:查看当前的账户信息和会员等级
  • +
  • 安全设置:修改认证密码
  • +
  • 外观设置:切换页面主题、界面外观
  • +
+
+ ) + }, + { + value: "scripts", + title: "9. 辅助脚本", + content: ( +
+

为了方便用户随时查看当前的实时积分收入,我们提供了开源的 Userscript 脚本。

+ +
+

+ 注:脚本完全开源且安全,仅通过官方 API 获取公开数据,不涉及任何敏感权限。 +

+
+
+ ) + } +] diff --git a/frontend/components/common/docs/legal-page-layout.tsx b/frontend/components/common/docs/legal-page-layout.tsx new file mode 100644 index 00000000..a6df8876 --- /dev/null +++ b/frontend/components/common/docs/legal-page-layout.tsx @@ -0,0 +1,416 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import { ArrowLeft, Calendar, Check, Copy, FileText } from "lucide-react" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { motion, useScroll, useSpring } from "motion/react" +import type { PolicySection } from "./types" + +interface LegalPageLayoutProps { + title: string + lastUpdated: string + sections: PolicySection[] + description?: React.ReactNode +} + +export function LegalPageLayout({ + title, + lastUpdated, + sections, + description, +}: LegalPageLayoutProps) { + const [activeSection, setActiveSection] = React.useState(sections[0]?.value || "") + const [hasCopied, setHasCopied] = React.useState(false) + const { scrollYProgress } = useScroll() + const contentRef = React.useRef(null) + const scaleX = useSpring(scrollYProgress, { + stiffness: 100, + damping: 30, + restDelta: 0.001 + }) + + const allSectionIds = React.useMemo(() => { + return sections.flatMap(section => [ + section.value, + ...(section.children?.map(child => child.value) || []) + ]) + }, [sections]) + + React.useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveSection(entry.target.id) + } + }) + }, + { rootMargin: "-20% 0px -35% 0px" } + ) + + allSectionIds.forEach((id) => { + const element = document.getElementById(id) + if (element) observer.observe(element) + }) + + return () => observer.disconnect() + }, [sections, allSectionIds]) + + const convertInlineNodes = (nodes: ChildNode[]): string => { + return nodes.map((node) => { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent || "" + } + if (node.nodeType !== Node.ELEMENT_NODE) { + return "" + } + const element = node as HTMLElement + const tagName = element.tagName.toLowerCase() + if (tagName === "code") { + return `\`${element.textContent?.trim() || ""}\`` + } + if (tagName === "strong" || tagName === "b") { + return `**${convertInlineNodes(Array.from(element.childNodes))}**` + } + if (tagName === "em" || tagName === "i") { + return `*${convertInlineNodes(Array.from(element.childNodes))}*` + } + if (tagName === "a") { + const text = convertInlineNodes(Array.from(element.childNodes)) || element.textContent || "" + const href = element.getAttribute("href") || "" + return href ? `[${text}](${href})` : text + } + if (tagName === "br") { + return "\n" + } + return convertInlineNodes(Array.from(element.childNodes)) + }).join("") + } + + const convertList = ( + list: HTMLOListElement | HTMLUListElement, + indentLevel: number + ): string => { + const isOrdered = list.tagName.toLowerCase() === "ol" + const items = Array.from(list.children).filter((child) => child.tagName.toLowerCase() === "li") + return items.map((item, index) => { + const prefix = isOrdered ? `${index + 1}. ` : "- " + const indent = " ".repeat(indentLevel) + const parts: string[] = [] + const itemElement = item as HTMLElement + const childNodes = Array.from(itemElement.childNodes) + const nestedLists: HTMLElement[] = [] + + childNodes.forEach((child) => { + if (child.nodeType === Node.ELEMENT_NODE) { + const childElement = child as HTMLElement + if (childElement.tagName.toLowerCase() === "ul" || childElement.tagName.toLowerCase() === "ol") { + nestedLists.push(childElement) + return + } + } + parts.push(convertInlineNodes([child]).trim()) + }) + + const baseLine = `${indent}${prefix}${parts.join("").trim()}` + const nestedMarkdown = nestedLists.map((nested) => convertList(nested as HTMLOListElement | HTMLUListElement, indentLevel + 1)) + return [baseLine, ...nestedMarkdown].filter(Boolean).join("\n") + }).join("\n") + } + + const convertTable = (table: HTMLTableElement): string => { + const rows = Array.from(table.querySelectorAll("tr")).map((row) => + Array.from(row.querySelectorAll("th,td")).map((cell) => (cell.textContent || "").trim()) + ) + if (!rows.length) return "" + const header = rows[0] + const body = rows.slice(1) + const headerLine = `| ${header.join(" | ")} |` + const separatorLine = `| ${header.map(() => "---").join(" | ")} |` + const bodyLines = body.map((row) => `| ${row.join(" | ")} |`) + return [headerLine, separatorLine, ...bodyLines].join("\n") + } + + const convertBlockNode = (node: HTMLElement, indentLevel: number): string => { + const tagName = node.tagName.toLowerCase() + if (tagName === "h3") return `### ${node.textContent?.trim() || ""}` + if (tagName === "h4") return `#### ${node.textContent?.trim() || ""}` + if (tagName === "p") return convertInlineNodes(Array.from(node.childNodes)).trim() + if (tagName === "ul" || tagName === "ol") return convertList(node as HTMLOListElement | HTMLUListElement, indentLevel) + if (tagName === "pre") { + const code = node.textContent || "" + return `\`\`\`\n${code.trim()}\n\`\`\`` + } + if (tagName === "table") return convertTable(node as HTMLTableElement) + if (tagName === "div") { + const hasTable = node.querySelector("table") + const hasList = node.querySelector("ul,ol") + const hasPre = node.querySelector("pre") + if (hasTable || hasList || hasPre) { + return Array.from(node.children) + .map((child) => convertBlockNode(child as HTMLElement, indentLevel)) + .filter(Boolean) + .join("\n\n") + } + const text = convertInlineNodes(Array.from(node.childNodes)).trim() + return text + } + return convertInlineNodes(Array.from(node.childNodes)).trim() + } + + const buildMarkdown = (): string => { + const contentRoot = contentRef.current + if (!contentRoot) return "" + const lines: string[] = [] + lines.push(`# ${title}`) + lines.push("") + lines.push(`最后更新:${lastUpdated}`) + if (description) { + const descriptionElement = contentRoot.querySelector("[data-docs-description]") + if (descriptionElement) { + const descriptionText = convertInlineNodes(Array.from(descriptionElement.childNodes)).trim() + if (descriptionText) { + lines.push("") + lines.push(descriptionText) + } + } + } + lines.push("") + + const sectionNodes = Array.from(contentRoot.querySelectorAll("[data-docs-section]")) as HTMLElement[] + sectionNodes.forEach((section) => { + const heading = section.querySelector("h2") + if (heading?.textContent) { + lines.push(`## ${heading.textContent.trim()}`) + lines.push("") + } + const prose = section.querySelector("[data-docs-content]") + if (prose) { + const blocks = Array.from(prose.children) + .map((child) => convertBlockNode(child as HTMLElement, 0)) + .filter(Boolean) + if (blocks.length) { + lines.push(blocks.join("\n\n")) + lines.push("") + } + } + }) + + return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n" + } + + const handleCopyMarkdown = async () => { + const markdown = buildMarkdown() + if (!markdown) return + try { + await navigator.clipboard.writeText(markdown) + setHasCopied(true) + setTimeout(() => setHasCopied(false), 2000) + } catch { + const textArea = document.createElement("textarea") + textArea.value = markdown + textArea.style.position = "fixed" + textArea.style.opacity = "0" + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + document.execCommand("copy") + document.body.removeChild(textArea) + setHasCopied(true) + setTimeout(() => setHasCopied(false), 2000) + } + } + + const scrollToSection = (e: React.MouseEvent, id: string) => { + e.preventDefault() + const element = document.getElementById(id) + if (element) { + const offset = 80 + const bodyRect = document.body.getBoundingClientRect().top + const elementRect = element.getBoundingClientRect().top + const elementPosition = elementRect - bodyRect + const offsetPosition = elementPosition - offset + + window.scrollTo({ + top: offsetPosition, + behavior: "smooth" + }) + } + } + + return ( +
+ + +
+
+
+
+ +
+
+ + + + + +

+ {title} +

+
+ + + {sections.length} 个内容 + + + + 最后更新:{lastUpdated} + + +
+ {description && ( +
+
+ {description} +
+
+ )} +
+
+ +
+ + +
+ {sections.map((section, index) => ( + +
+ + {index + 1} + +

+ {section.title.replace(/^\d+\.\s*/, '')} +

+
+ +
+ {section.content} +
+
+ ))} + +
+
+

+ 对这些内容有疑问? +

+
+ + + + + + +
+
+
+
+
+
+
+ ) +} diff --git a/frontend/components/common/docs/privacy.tsx b/frontend/components/common/docs/privacy.tsx new file mode 100644 index 00000000..3c98e992 --- /dev/null +++ b/frontend/components/common/docs/privacy.tsx @@ -0,0 +1,110 @@ +import { type PolicySection } from "./types" + +/** + * ------------------------------------------------------------------ + * 隐私政策 (PRIVACY POLICY) + * ------------------------------------------------------------------ + */ +export const privacySections: PolicySection[] = [ + { + value: "collection-details", + title: "1. 详细信息收集说明", + content: ( +
+

我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:

+
+
+ 1.1 身份鉴权信息: +

当您通过 LINUX DO Connect 登录时,我们会获取您的社区 OpenID(唯一标识符)、加密后的用户名及头像 URL。我们不收集您的手机号、真实姓名或身份证件信息。

+
+
+ 1.2 服务日志信息: +

为保障系统运行安全及满足法律合规要求,我们会自动收集您的操作日志,包括 IP 地址、访问日期和时间、API 调用记录、User-Agent(浏览器/设备类型)。

+
+
+ 1.3 交易与资产信息: +

若您使用支付功能,我们将记录您的商户订单号、交易金额、交易时间、交易状态摘要。这些信息是账务核对的必要依据。

+
+
+
+ ), + }, + { + value: "storage-security", + title: "2. 数据存储与安全保护", + content: ( +
+

我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:

+
    +
  • 存储地点:依照法规要求,我们收集和产生的用户个人信息,存储在独立的服务器上。我们不会将您的数据传输至境外管辖区。
  • +
  • 加密技术:敏感数据(如 支付密码)在数据库中均采用高强度加密算法存储。数据传输全链路采用 SSL/TLS 1.3 协议进行加密,防止网络嗅探。
  • +
  • 隔离机制:本平台数据与外部网络物理隔离,且独立于 LINUX DO 社区论坛主数据库,确保单一系统故障不会波及全局数据安全。
  • +
  • 访问控制:我们实行严格的最小权限原则(Least Privilege),仅有核心运维人员经授权后方可访问必要的维护数据,且所有操作均有审计日志留存。
  • +
+
+ ), + }, + { + value: "usage-rules", + title: "3. 信息使用规范", + content: ( +
+

我们收集的信息将仅用于以下目的:

+
    +
  • 身份识别:用于确认您的社区身份,展示您的个人中心数据。
  • +
  • 业务功能:处理您的支付指令、API 请求、回调通知及账单生成。
  • +
  • 安全风控:利用 IP 及行为日志进行反作弊、反欺诈分析,识别恶意攻击行为,保护平台及其他用户的安全。
  • +
  • 客户支持:在您发起工单或申诉时,查询相关日志以协助您解决问题。
  • +
+

禁止用途:我们承诺绝不利用您的数据进行用户画像分析、个性化广告推送或商业营销。

+
+ ), + }, + { + value: "sharing-disclosure", + title: "4. 信息共享与对外披露", + content: ( +
+

4.1 共享原则:我们坚持数据零共享策略。除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、支付宝、微信、银行、广告商)共享您的个人信息:

+
    +
  • 事先获得您的明确授权或同意;
  • +
  • 根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。
  • +
+

4.2 转让与公开披露:我们不会将您的个人信息转让给任何公司、组织和个人。我们仅在法律法规强制要求,或为了保护平台及用户与公众的人身财产安全免受侵害时,才会公开披露您的信息。

+
+ ), + }, + { + value: "user-rights", + title: "5. 您的权利与数据管理", + content: ( +
+

依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:

+
+
+ 5.1 查阅与复制权: +

您可以随时登录开发者后台,查阅您的概览信息、API Key 状态及历史交易账单。您可以通过“导出账单”功能获取您的数据副本。

+
+
+ 5.2 删除与遗忘权: +

若您决定停止使用本服务,在结清所有应付费用及余额后,您可以申请注销账户。注销后,我们将立即删除您的所有敏感信息或进行匿名化处理,法律法规规定需保留的日志除外。

+
+
+ 5.3 纠正与更正权: +

若您发现您的信息有误,您有权要求我们更正或补充。您可以在设置页面直接修改您的信息,或通过客服提交工单。

+
+
+
+ ), + }, + { + value: "policy-update", + title: "6. 政策更新与通知", + content: ( +
+

随着业务的发展或法律法规的变动,我们可能会适时修订本《隐私政策》。

+

当条款发生重大变更时(例如收集范围扩大、使用目的改变),我们会通过站内信、公告或弹窗等显著方式通知您。若您在政策更新后继续使用本服务,即表示您同意接受更新后的隐私政策约束。

+
+ ), + }, +] diff --git a/frontend/components/common/docs/terms.tsx b/frontend/components/common/docs/terms.tsx new file mode 100644 index 00000000..87cd4e5f --- /dev/null +++ b/frontend/components/common/docs/terms.tsx @@ -0,0 +1,115 @@ +import { type PolicySection } from "./types" + +export const TERMS_LAST_UPDATED = "2025-12-22" + +/** + * ------------------------------------------------------------------ + * 服务条款 (TERMS OF SERVICE) + * ------------------------------------------------------------------ + */ +export const termsSections: PolicySection[] = [ + { + value: "contract-establishment", + title: "1. 缔约申明与服务综述", + content: ( +
+

1.1 缔约主体:本《服务协议》(以下简称“本协议”)是您(以下亦称“社区用户”、“开发者”、“消费方”或“服务方”)与 LINUX DO Credit 平台运营团队(以下简称“平台”、“我们”)之间关于使用平台服务所订立的具有法律约束力的契约。

+

1.2 审慎阅读:请您务必审慎阅读、充分理解各条款内容,特别是免除或者限制责任的条款、争议解决和法律适用条款。各免责或限责条款将以粗体标识,您应重点阅读。如您不同意本协议的任何内容,请立即停止注册或使用本服务。

+

1.3 协议构成:本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、声明、说明。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。

+
+ ), + }, + { + value: "service-definition", + title: "2. 服务定义与性质界定", + content: ( +
+

2.1 社区技术服务:LINUX DO Credit 是基于 LINUX DO 社区生态构建的独立价值交换协议与技术系统。我们仅提供 API 接口调用、数据路由、账单管理等纯技术服务。

+

2.2 非金融机构申明:

+
    +
  • 非银行机构:我们不是商业银行、持牌支付机构(如支付宝、微信支付、银联)或清算机构。
  • +
  • 不提供资金沉淀:平台不设立资金池,不提供真实法币存取款、转账汇款或支付结算服务。所有涉及资金流转的行为均发生于社区用户与社区支付渠道之间,不涉及真实货币。
  • +
  • 不提供金融服务:平台不提供任何金融服务,包括但不限于贷款、融资、投资、理财、保险等金融服务。
  • +
  • 不提供积分兑现:平台不提供任何积分兑换服务,包括但不限于积分兑换为真实货币、积分兑换为实物商品、积分兑换为服务等。
  • +
  • 不提供真实货币交易:平台不提供任何真实货币交易服务,包括但不限于真实货币交易为积分、真实货币交易为虚拟资产、真实货币交易为服务等。
  • +
+

2.3 服务限定:本平台建议用于仅支持虚拟商品、软件授权、技术咨询、会员订阅等无实物交付的场景。关于实物电商、物流发货或涉及线下履约的商业场景造成的任何后果我们概不负责,请妥善保管好自己的个人财产,谨防上当受骗。

+
+ ), + }, + { + value: "account-specifications", + title: "3. 账号注册与使用规范", + content: ( +
+

3.1 账号体系:本平台采用 LINUX DO Connect (OAuth) 授权登录体系。您必须拥有合法、有效的 LINUX DO 社区账号方可使用本服务。您的平台账号权益(包括但不限于信誉分、等级)与社区账号严格绑定。

+

3.2 匿名性与真实性:

+
    +
  • 无需实名:我们尊重您的隐私,不强制要求您提供居民身份证、护照或营业执照进行实名认证。
  • +
  • 操作真实性:您承诺注册和使用的账号是您本人操作。严禁恶意注册、挂机脚本、自动化程序注册等破坏平台公平性的行为。
  • +
+

3.3 账号安全责任:您应妥善保管您账号的支付密码、Client ID 和 Client Secret。因您保管不善可能导致账号被他人非法使用、资金损失或数据泄露的责任,由您自行承担。如发现账号异常,请立即通知我们进行冻结。

+
+ ), + }, + { + value: "user-conduct", + title: "4. 用户行为准则(负面清单)", + content: ( +
+

您在使用本服务时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。严禁利用本平台从事以下活动(“红线条款”):

+
+
    +
  • 危害国家安全:反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;
  • +
  • 非法信息服务:黑客攻击工具、DDoS 攻击服务、服务器爆破等其他非法信息服务平台;
  • +
  • 黄赌毒关联:制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;
  • +
  • 侵犯知识产权:销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;
  • +
  • 欺诈与虚假:进行电信诈骗、金融诈骗、传销、虚假广告虚假交易等;
  • +
  • 其他违法信息:涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。
  • +
+
+

违约处理:一旦发现您违反上述规定,平台有权不经通知立即永久封禁您的账号、拦截所有 API 请求、冻结账户内所有关联价值,并依法向公安机关、网安部门移交相关线索。

+
+ ), + }, + { + value: "virtual-assets", + title: "5. 虚拟资产与交易规则", + content: ( +
+

5.1 资产性质:平台内流转的“余额”、“积分”等均为社区虚拟积分,仅代表您在社区生态内的活跃度或贡献值。它们不具有任何货币属性,严禁兑换为法定货币,也不可用于任何非平台许可的商业交易。

+

5.2 交易不可逆:鉴于网络技术的实时性,一旦积分消耗或划转指令被执行,该操作即不可撤销。请您在确认相关积分活动前,务必仔细核对服务方信息。

+

5.3 规则说明:为营造良好的社区积分环境,平台有权收取一定的服务费(以积分为结算单位)进行调控,具体规则以控制台公示为准。平台保留根据社区运营状况调整积分规则的权利。

+
+ ), + }, + { + value: "liability-limitation", + title: "6. 免责声明与不可抗力", + content: ( +
+

6.1 基础免责:本平台服务按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的要求,也不保证服务不会中断,对服务的及时性、安全性、准确性都不作担保。

+

6.2 不可抗力:对于因以下原因导致的服务中断、数据丢失或账号损失,平台不承担赔偿责任:

+
    +
  • 自然灾害(台风、地震、海啸、洪水等);
  • +
  • 政府行为、法律法规或政策调整、行政命令;
  • +
  • 电信部门技术调整、通讯线路中断、海底光缆故障;
  • +
  • 黑客攻击、计算机病毒侵入或发作、技术性故障;
  • +
  • 社区维护、系统升级(我们将尽可能提前公告)。
  • +
+

6.3 责任上限:在任何情况下,平台对您所承担的违约赔偿责任总额不超过您在违约行为发生前 1 个月内向平台支付的费用总额。

+
+ ), + }, + { + value: "governing-law", + title: "7. 法律适用与争议解决", + content: ( +
+

7.1 法律适用:本协议的订立、执行、解释及争议的解决均适用中华人民共和国法律(不包括港澳台地区法律及冲突法)。

+

7.2 争议解决:若您和平台发生任何争议或纠纷,首先应友好协商解决;协商不成的,您同意将纠纷或争议提交至平台运营团队所在地有管辖权的人民法院管辖。

+

7.3 协议变更:我们有权根据法律法规变化或业务发展需要修改本协议。变更后的协议将在平台公示,自公示之日起生效。若您继续使用服务,视为您已接受修订后的协议。

+
+ ), + }, +] diff --git a/frontend/components/common/docs/types.ts b/frontend/components/common/docs/types.ts new file mode 100644 index 00000000..1e5c24a8 --- /dev/null +++ b/frontend/components/common/docs/types.ts @@ -0,0 +1,11 @@ +import { type ReactNode } from "react" + +export type PolicySection = { + value: string + title: string + content: ReactNode + children?: { + value: string + title: string + }[] +} diff --git a/frontend/components/common/general/dispute-dialog.tsx b/frontend/components/common/general/dispute-dialog.tsx new file mode 100644 index 00000000..37e15106 --- /dev/null +++ b/frontend/components/common/general/dispute-dialog.tsx @@ -0,0 +1,760 @@ +"use client" + +import * as React from "react" +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { Spinner } from "@/components/ui/spinner" +import { AlertTriangle, X, Layers, Banknote, TicketSlash } from "lucide-react" +import { formatDateTime } from "@/lib/utils" +import type { Order, DisputeWithOrder, Dispute } from "@/lib/services" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import { DisputeService } from "@/lib/services" +import { toast } from "sonner" +import { useIsMobile } from "@/hooks/use-mobile" +import { usePublicConfig } from "@/hooks/use-public-config" +import { useTransaction } from "@/contexts/transaction-context" +import { useUser } from "@/contexts/user-context" +import { Textarea } from "@/components/ui/textarea" +import { typeConfig } from "@/components/common/general/table-filter" +import { VisuallyHidden } from "@radix-ui/react-visually-hidden" + +type DisputeViewer = 'payer' | 'payee' + +/** + * 活动详情弹窗 + */ +export function OrderDetailDialog({ order }: { order: Order }) { + const [open, setOpen] = useState(false) + + return ( + <> + + + + + +

查看详情

+
+
+ + + + + 积分流转详情 + 查看积分活动的详细信息 + + +
+
+

{order.app_name || 'RECEIPT'}

+

+ {formatDateTime(order.created_at)} +

+
+ +
+

Total Credits

+
+ {parseFloat(order.amount).toFixed(2)} +
+
+ +
+
+ 编号 + {order.order_no} +
+
+ 类型 + {typeConfig[order.type]?.label || order.type} +
+
+ 消费方 + {order.payer_username} +
+
+ 服务方 + {order.payee_username} +
+
+ 备注 + {order.remark} +
+ {(order.status === 'success' || order.status === 'refund') && ( +
+ 时间 + {formatDateTime(order.trade_time)} +
+ )} +
+ +
+
+ {[...Array(30)].map((_, i) => ( +
+ ))} +
+

+ LINUX DO Credit +

+
+
+ +
+ + ) +} + +/** + * 发起争议弹窗 + */ +export function CreateDisputeDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) { + const isMobile = useIsMobile() + const { config: publicConfig, loading: configLoading, error: configError } = usePublicConfig() + const { updateOrderStatus } = useTransaction() + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [reason, setReason] = useState("") + + const resetForm = () => { + setReason("") + } + + const handleButtonClick = async () => { + if (configLoading) { + try { + await new Promise(resolve => setTimeout(resolve, 100)) + if (configLoading) { + toast.error('配置加载中', { description: '请稍候再试' }) + return + } + } catch { + toast.error('配置加载失败', { description: '请刷新页面重试' }) + return + } + } + + if (configError || !publicConfig) { + toast.error('无法获取配置', { description: configError?.message || '请刷新页面重试' }) + return + } + + const tradeTime = new Date(order.trade_time) + const expiryTime = new Date(tradeTime.getTime() + publicConfig.dispute_time_window_hours * 60 * 60 * 1000) + const now = new Date() + + if (now > expiryTime) { + toast.error('无法发起争议', { description: '已超过最晚发起争议时间' }) + return + } + + setOpen(true) + } + + const handleOpenChange = (newOpen: boolean) => { + if (newOpen && !loading) { + resetForm() + } + setOpen(newOpen) + } + + const handleCreateDispute = async () => { + if (!reason.trim()) { + toast.error('表单验证失败', { description: '请填写争议原因' }) + return + } + + if (reason.length > 100) { + toast.error('表单验证失败', { description: '争议原因不能超过 100 个字符' }) + return + } + + try { + setLoading(true) + const dispute = await DisputeService.createDispute({ order_id: order.id, reason: reason.trim() }) + toast.success('争议已发起', { description: '请等待服务方处理' }) + updateOrderStatus(order.id, { + status: 'disputing', + dispute_id: dispute.id + }) + setOpen(false) + onSuccess?.() + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : '发起争议失败' + toast.error('发起争议失败', { description: errorMessage }) + } finally { + setLoading(false) + } + } + + return ( + <> + + + + + +

发起争议

+
+
+ + + + 发起争议 + + 如果您对该积分活动有疑问,可以发起争议,服务方将在规定时间内处理。 + + + +
+
+ +