Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ projectApp:
- interval_seconds: 60
max_count: 20

# captcha (hCaptcha) - 领取接口的人机验证由后端校验
# 本地开发可用 hCaptcha 测试密钥(配合测试 sitekey 10000000-ffff-ffff-ffff-000000000001):
# secret_key: "0x0000000000000000000000000000000000000000"
captcha:
secret_key: "<HCAPTCHA_SECRET_KEY>"
verify_url: "https://api.hcaptcha.com/siteverify"

# OAuth2
oauth2:
client_id: "<OAUTH2_CLIENT_ID>"
Expand Down
52 changes: 52 additions & 0 deletions docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,47 @@ const docTemplate = `{
}
}
},
"/api/v1/projects/{id}/receive/token": {
"get": {
"description": "项目可领取时签发一次性凭证,领取时需与 captcha_token 一并提交",
"produces": [
"application/json"
],
"tags": [
"project"
],
"summary": "获取领取凭证",
"parameters": [
{
"type": "string",
"description": "项目ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/project.ProjectResponse"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/project.ReceiveTokenResponseData"
}
}
}
]
}
}
}
}
},
"/api/v1/projects/{id}/receivers": {
"get": {
"consumes": [
Expand Down Expand Up @@ -1422,6 +1463,17 @@ const docTemplate = `{
}
}
},
"project.ReceiveTokenResponseData": {
"type": "object",
"properties": {
"expires_in": {
"type": "integer"
},
"token": {
"type": "string"
}
}
},
"project.ReportProjectRequestBody": {
"type": "object",
"required": [
Expand Down
52 changes: 52 additions & 0 deletions docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,47 @@
}
}
},
"/api/v1/projects/{id}/receive/token": {
"get": {
"description": "项目可领取时签发一次性凭证,领取时需与 captcha_token 一并提交",
"produces": [
"application/json"
],
"tags": [
"project"
],
"summary": "获取领取凭证",
"parameters": [
{
"type": "string",
"description": "项目ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/project.ProjectResponse"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/project.ReceiveTokenResponseData"
}
}
}
]
}
}
}
}
},
"/api/v1/projects/{id}/receivers": {
"get": {
"consumes": [
Expand Down Expand Up @@ -1413,6 +1454,17 @@
}
}
},
"project.ReceiveTokenResponseData": {
"type": "object",
"properties": {
"expires_in": {
"type": "integer"
},
"token": {
"type": "string"
}
}
},
"project.ReportProjectRequestBody": {
"type": "object",
"required": [
Expand Down
31 changes: 31 additions & 0 deletions docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,13 @@ definitions:
label:
type: string
type: object
project.ReceiveTokenResponseData:
properties:
expires_in:
type: integer
token:
type: string
type: object
project.ReportProjectRequestBody:
properties:
reason:
Expand Down Expand Up @@ -823,6 +830,30 @@ paths:
summary: 获取当前用户的待支付订单
tags:
- payment
/api/v1/projects/{id}/receive/token:
get:
description: 项目可领取时签发一次性凭证,领取时需与 captcha_token 一并提交
parameters:
- description: 项目ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/project.ProjectResponse'
- properties:
data:
$ref: '#/definitions/project.ReceiveTokenResponseData'
type: object
summary: 获取领取凭证
tags:
- project
/api/v1/projects/{id}/receivers:
get:
consumes:
Expand Down
1 change: 0 additions & 1 deletion frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
FRONTEND_BASE_URL=http://localhost:3000
BACKEND_BASE_URL=http://localhost:8000
NEXT_PUBLIC_HCAPTCHA_SITE_KEY=YOUR_NEXT_PUBLIC_HCAPTCHA_SITE_KEY
HCAPTCHA_SECRET_KEY=YOUR_HCAPTCHA_SECRET_KEY
2 changes: 0 additions & 2 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ ENV NODE_ENV=production
ENV NEXT_PUBLIC_FRONTEND_BASE_URL=__NEXT_PUBLIC_FRONTEND_BASE_URL__
ENV NEXT_PUBLIC_BACKEND_BASE_URL=https://build-placeholder.invalid
ENV NEXT_PUBLIC_HCAPTCHA_SITE_KEY=__NEXT_PUBLIC_HCAPTCHA_SITE_KEY__
ENV HCAPTCHA_SECRET_KEY=__HCAPTCHA_SECRET_KEY__

ARG VERSION=""
ARG BUILD_DATE=""
Expand All @@ -37,7 +36,6 @@ RUN grep -rl \
-e "__NEXT_PUBLIC_FRONTEND_BASE_URL__" \
-e "https://build-placeholder.invalid" \
-e "__NEXT_PUBLIC_HCAPTCHA_SITE_KEY__" \
-e "__HCAPTCHA_SECRET_KEY__" \
/app/.next > /app/.replace.files

# ---- runner stage ----
Expand Down
25 changes: 21 additions & 4 deletions frontend/components/common/receive/ReceiveContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ export function ReceiveContent({data}: ReceiveContentProps) {
const [isCheckingPendingPayment, setIsCheckingPendingPayment] = useState(false);
const [isContinuingPayment, setIsContinuingPayment] = useState(false);
const verifyRef = useRef<ReceiveVerifyRef>(null);
const receiveTokenRef = useRef<string | null>(null);

/**
* 检查项目是否可以领取(时间限制)
Expand All @@ -315,7 +316,7 @@ export function ReceiveContent({data}: ReceiveContentProps) {
/**
* 处理项目领取(触发验证)
*/
const handleReceive = () => {
const handleReceive = async () => {
if (!projectId || hasReceived || isVerifying) return;

// 检查项目时间
Expand All @@ -331,7 +332,16 @@ export function ReceiveContent({data}: ReceiveContentProps) {
return;
}

// 触发验证
// 先向服务端获取一次性领取凭证,再触发验证;验证通过后两者一起提交
setIsVerifying(true);
try {
const {token} = await services.project.getReceiveToken(projectId);
receiveTokenRef.current = token;
} catch (error) {
setIsVerifying(false);
toast.error(error instanceof Error ? error.message : '获取领取凭证失败');
return;
}
verifyRef.current?.execute();
};

Expand Down Expand Up @@ -367,8 +377,14 @@ export function ReceiveContent({data}: ReceiveContentProps) {
* 验证成功后处理
*/
const handleVerifySuccess = async (token: string) => {
// 调用领取接口
const result = await services.project.receiveProjectSafe(projectId, token);
const receiveToken = receiveTokenRef.current;
receiveTokenRef.current = null;
if (!receiveToken) {
toast.error('领取凭证已失效,请重试');
throw new Error('missing receive token');
}
// 调用领取接口:验证码 + 领取凭证一起提交
const result = await services.project.receiveProjectSafe(projectId, token, receiveToken);

if (!result.success) {
toast.error(result.error || '领取失败');
Expand Down Expand Up @@ -407,6 +423,7 @@ export function ReceiveContent({data}: ReceiveContentProps) {
* 验证结束回调
*/
const handleVerifyEnd = () => {
receiveTokenRef.current = null;
setIsVerifying(false);
};

Expand Down
1 change: 0 additions & 1 deletion frontend/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,5 @@ replace_placeholder() {
replace_placeholder "__NEXT_PUBLIC_FRONTEND_BASE_URL__" "$NEXT_PUBLIC_FRONTEND_BASE_URL"
replace_placeholder "https://build-placeholder.invalid" "$NEXT_PUBLIC_BACKEND_BASE_URL"
replace_placeholder "__NEXT_PUBLIC_HCAPTCHA_SITE_KEY__" "$NEXT_PUBLIC_HCAPTCHA_SITE_KEY"
replace_placeholder "__HCAPTCHA_SECRET_KEY__" "$HCAPTCHA_SECRET_KEY"

exec "$@"
24 changes: 20 additions & 4 deletions frontend/lib/services/project/project.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
ProjectListResponse,
ApiRequestParams,
ReceiveProjectData,
ReceiveTokenData,
ReceiveTokenResponse,
ReportProjectResponse,
ProjectReceiver,
ProjectReceiversResponse,
Expand Down Expand Up @@ -120,16 +122,30 @@ export class ProjectService extends BaseService {
}

/**
* 领取项目内容(必须带验证码)
* 获取领取凭证(服务端签发的一次性 token)
* @param projectId - 项目ID
*/
static async getReceiveToken(projectId: string): Promise<ReceiveTokenData> {
const response = await apiClient.get<ReceiveTokenResponse>(`${this.basePath}/${projectId}/receive/token`);
if (response.data.error_msg) {
throw new Error(response.data.error_msg);
}
return response.data.data;
}

/**
* 领取项目内容(验证码 + 领取凭证一起提交)
* @param projectId - 项目ID
* @param captchaToken - hCaptcha验证令牌
* @param receiveToken - 服务端签发的领取凭证
* @returns 领取结果,包含领取内容
*/
static async receiveProject(projectId: string, captchaToken: string): Promise<ReceiveProjectData> {
static async receiveProject(projectId: string, captchaToken: string, receiveToken: string): Promise<ReceiveProjectData> {
const response = await apiClient.post<ReceiveProjectResponse>(
`${this.basePath}/${projectId}/receive`,
{
captcha_token: captchaToken,
receive_token: receiveToken,
},
);
if (response.data.error_msg) {
Expand Down Expand Up @@ -370,13 +386,13 @@ export class ProjectService extends BaseService {
* @param captchaToken - hCaptcha验证令牌
* @returns 领取结果,包含成功状态、领取内容和错误信息
*/
static async receiveProjectSafe(projectId: string, captchaToken: string): Promise<{
static async receiveProjectSafe(projectId: string, captchaToken: string, receiveToken: string): Promise<{
success: boolean;
data?: ReceiveProjectData;
error?: string;
}> {
try {
const data = await this.receiveProject(projectId, captchaToken);
const data = await this.receiveProject(projectId, captchaToken, receiveToken);
return {success: true, data};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '领取项目内容失败';
Expand Down
12 changes: 12 additions & 0 deletions frontend/lib/services/project/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,18 @@ export interface ReceiveProjectData {
*/
export type ReceiveProjectResponse = BackendResponse<ReceiveProjectData>;

/**
* 领取凭证
*/
export interface ReceiveTokenData {
/** 一次性凭证,领取时随 captcha_token 一并提交 */
token: string;
/** 有效期(秒) */
expires_in: number;
}

export type ReceiveTokenResponse = BackendResponse<ReceiveTokenData>;

/**
* 获取项目详情响应数据
*/
Expand Down
Loading
Loading