diff --git a/alembic/versions/b8949439e623_add_userid_and_ip_to_paper_review.py b/alembic/versions/b8949439e623_add_userid_and_ip_to_paper_review.py new file mode 100644 index 0000000..f31d749 --- /dev/null +++ b/alembic/versions/b8949439e623_add_userid_and_ip_to_paper_review.py @@ -0,0 +1,32 @@ +"""add userid and ip to paper_review + +Revision ID: b8949439e623 +Revises: abc123456789 +Create Date: 2025-08-20 16:57:40.605076 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b8949439e623' +down_revision: Union[str, None] = 'abc123456789' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('paper_review', sa.Column('userid', sa.String(length=128), nullable=True)) + op.add_column('paper_review', sa.Column('ip', sa.String(length=45), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('paper_review', 'ip') + op.drop_column('paper_review', 'userid') + # ### end Alembic commands ### diff --git a/app/api/agent_review.py b/app/api/agent_review.py index 89499c0..48cbf9b 100644 --- a/app/api/agent_review.py +++ b/app/api/agent_review.py @@ -1,15 +1,17 @@ +import traceback from datetime import datetime from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException, Request -from app.crud import create_paper_review, get_reviews +from app.crud import create_paper_review, get_reviews, check_if_exist from app.database import get_db from app.schemas import SubmitReviewIn, Review, SubmitReviewOut, GetReviewOut, GetReviewIn -from app.constants import AgentType, DocType, ResponseCode +from app.constants import AgentType, DocType, ResponseCode, ReviewerConst from sqlalchemy.orm import Session from app.config import settings import logging +from datetime import datetime, timedelta, timezone logger = logging.getLogger(__name__) @@ -45,15 +47,15 @@ async def submit_review( # Avoid failing the request due to logging issues pass - # Save a place for check if the paper is exist - # rec = check_if_exist( - # db=db, aixiv_id=review.aixiv_id, version=review.version, doc_type=review.doc_type - # ) - # if rec is None: - # raise HTTPException( - # status_code=400, - # detail=f"Submission with aixiv_id={review.aixiv_id} and version={review.version} does not exist" - # ) + if settings.paper_exist_check: + rec = check_if_exist( + db=db, aixiv_id=review.aixiv_id, version=review.version, doc_type=review.doc_type + ) + if rec is None: + raise HTTPException( + status_code=400, + detail=f"Review submission with aixiv_id={review.aixiv_id} and version={review.version} and doc_type={review.doc_type} does not exist" + ) agent_type_val, doc_type_val = _resolve_agent_and_doc( reviewer=review.reviewer, @@ -61,11 +63,21 @@ async def submit_review( token=review.token, ) + if settings.ip_limit_window_size > 0: + start_time = datetime.now(timezone.utc) - timedelta(hours=settings.ip_limit_window_size) + rec = get_reviews(db, review.aixiv_id, start_time, datetime.now(timezone.utc), review.version, client_ip, doc_type_val) + if len(rec) > settings.ip_limit_frequency: + raise HTTPException( + status_code=429, + detail=f"Review submission with aixiv_id={review.aixiv_id} and version={review.version} and doc_type={review.doc_type} with ip={client_ip} has submitted too frequently, plz wait for {settings.ip_limit_window_size} hour to retry." + ) + rec = create_paper_review( db=db, payload=review, agent_type=agent_type_val, - doc_type=doc_type_val + doc_type=doc_type_val, + ip=client_ip ) return SubmitReviewOut( @@ -74,10 +86,24 @@ async def submit_review( version=rec.version, id=rec.id ) + + except HTTPException: + raise + except Exception as e: + logger.error({ + "event": "submit-review:error", + "aixiv_id": review.aixiv_id, + "version": review.version, + "doc_type": review.doc_type, + "reviewer": review.reviewer, + "error_message": str(e), + "traceback": traceback.format_exc(), + }) + raise HTTPException( - status_code = ResponseCode.INTERNAL_ERROR, - detail=f"submit failed: {str(e)}" + status_code=ResponseCode.INTERNAL_ERROR, + detail="submit failed: internal server error" ) @@ -103,11 +129,30 @@ async def get_review( pass reviews = get_reviews(db, query.aixiv_id, query.start_date, query.end_date, query.version) + + reviews_list = [Review( + aixiv_id=r.aixiv_id, + version=r.version, + review_results=r.review_results, + create_time=r.create_time, + reviewer=ReviewerConst.REVIEWERS_TYPE_MAP.get(r.agent_type, ReviewerConst.UNKNOWN_REVIEWER), + ) for r in reviews] + return GetReviewOut( - review_list=reviews, + review_list=reviews_list, code=ResponseCode.SUCCESS ) except Exception as e: + logger.info({ + "event": "get-review:request", + "aixiv_id": query.aixiv_id, + "version": query.version, + "start_date": query.start_date.isoformat() if query.start_date else None, + "end_date": query.end_date.isoformat() if query.end_date else None, + "error_message": str(e), + "traceback": traceback.format_exc(), + }) + raise HTTPException( status_code = ResponseCode.INTERNAL_ERROR, detail=f"query failed: {str(e)}" diff --git a/app/config.py b/app/config.py index 674aaf7..383df46 100644 --- a/app/config.py +++ b/app/config.py @@ -24,6 +24,10 @@ class Settings(BaseSettings): secret_key: str = os.getenv("SECRET_KEY", "your-secret-key-here") debug: bool = os.getenv("DEBUG", "True").lower() == "true" auth_token: str = os.getenv("AUTH_TOKEN", "your-auth-token-here") + paper_exist_check: bool = os.getenv("PAPER_EXIST_CHECK", False) + ip_limit_window_size: int = os.getenv("IP_LIMIT_WINDOWSiZE", 0) + ip_limit_frequency: int = os.getenv("IP_LIMIT_FREQUENCY", 3) + # CORS Configuration - handle both env var and default @property diff --git a/app/crud.py b/app/crud.py index 7d505e8..66d8011 100644 --- a/app/crud.py +++ b/app/crud.py @@ -189,14 +189,16 @@ def create_paper_review( db: Session, payload: SubmitReviewIn, agent_type: int = AgentType.agent.value, - doc_type: int = DocType.paper.value + doc_type: int = DocType.paper.value, + ip: Optional[str] = None ) -> PaperReview: rec = PaperReview( aixiv_id = payload.aixiv_id, version = payload.version, review_results = payload.review_results, agent_type = agent_type, - doc_type = doc_type + doc_type = doc_type, + ip = ip ) db.add(rec) db.commit() @@ -209,34 +211,31 @@ def get_reviews( aixiv_id: str, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, - version: Optional[str] = None -) -> list[Review]: + version: Optional[str] = None, + ip: Optional[str] = None, + doc_type: Optional[int] = None +) -> list[type[PaperReview]]: query = db.query(PaperReview).filter(PaperReview.aixiv_id == aixiv_id) if start_date: query = query.filter(PaperReview.create_time >= start_date) if end_date: query = query.filter(PaperReview.create_time <= end_date) - if version is not None: + if version: query = query.filter(PaperReview.version == version) + if ip: + query = query.filter(PaperReview.ip == ip) + if doc_type: + query = query.filter(PaperReview.doc_type == doc_type) reviews = query.all() - - reviews_list = [Review( - aixiv_id=r.aixiv_id, - version=r.version, - review_results=r.review_results, - create_time=r.create_time, - reviewer=ReviewerConst.REVIEWERS_TYPE_MAP.get(r.agent_type, ReviewerConst.UNKNOWN_REVIEWER), - ) for r in reviews] - - return reviews_list + return reviews # Check if th paper is exitst -# def check_if_exist(db: Session, aixiv_id: str, version: str, doc_type: str) -> Optional[Submission]: -# record = ( -# db.query(Submission) -# .filter(Submission.aixiv_id == aixiv_id, Submission.version == version, Submission.category == doc_type) -# .first() -# ) -# -# return record +def check_if_exist(db: Session, aixiv_id: str, version: str, doc_type: str) -> Optional[Submission]: + record = ( + db.query(Submission) + .filter(Submission.aixiv_id == aixiv_id, Submission.version == version, Submission.doc_type == doc_type) + .first() + ) + + return record diff --git a/app/models.py b/app/models.py index f954bf2..e52f9c4 100644 --- a/app/models.py +++ b/app/models.py @@ -72,6 +72,8 @@ class PaperReview(Base): TIMESTAMP, nullable=False, server_default=func.now() ) like_count = Column(Integer, nullable=False, server_default=text("0")) + userid = Column(String(128), nullable=True) + ip = Column(String(45), nullable=True) __table_args__ = ( Index("idx_paper_review_aixiv_id_create_time", "aixiv_id", "create_time"), diff --git a/app/schemas.py b/app/schemas.py index 9fedd46..080e770 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -156,6 +156,12 @@ def validate_aixiv_id(cls, v: str): return v + @field_validator("version") + def validate_version(cls, v: str): + pattern = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)(\.(0|[1-9]\d*))?$" + if not re.match(pattern, v): + raise ValueError("version must be in the format 'X.Y' or 'X.Y.Z', e.g. 1.0, 2.1, 1.9.3") + return v class SubmitReviewOut(BaseModel): code: int @@ -184,6 +190,12 @@ def lowercase_fields(cls, v): return v.lower() return v + @field_validator("version") + def validate_version(cls, v: str): + pattern = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)(\.(0|[1-9]\d*))?$" + if not re.match(pattern, v): + raise ValueError("version must be in the format 'X.Y' or 'X.Y.Z', e.g. 1.0, 2.1, 1.9.3") + return v class GetReviewOut(BaseModel): review_list: List[Review] diff --git a/env.example b/env.example index 491f47e..18fcca6 100644 --- a/env.example +++ b/env.example @@ -32,4 +32,7 @@ AWS_S3_BUCKET=aixiv-papers SECRET_KEY=your_secret_key_here DEBUG=True ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 -AUTH_TOKEN=your-auth-token-here \ No newline at end of file +AUTH_TOKEN=your-auth-token-here +IP_LIMIT_WINDOW_SIZE=0 #for prevent IP frequently submit reviews, 0 for turn the lock off, 1 for 1 hour etc. +IP_LIMIT_FREQUENCY=0 #for prevent IP frequently submit reviews, means for each IP_LIMIT_WINDOWSiZE limit, accept IP_LIMIT_FREQUENCY reviews. +PAPER_EXIST_CHECK=True #for check the target paper is existed or not in the submissions table \ No newline at end of file