diff --git a/backend/app/database.py b/backend/app/database.py index c1a049f..ec465df 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -24,4 +24,12 @@ def get_db(): try: yield db finally: - db.close() + # A request may exit through an exception or an early return before a + # route explicitly commits. Roll back first so any checked-out + # connection is returned to QueuePool in a clean state, then close the + # Session itself. Session.close() also rolls back, but doing it here + # makes the lifecycle guarantee explicit and observable in tests. + try: + db.rollback() + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py index d81e1b1..3dcbd13 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -85,6 +85,18 @@ def _product_intent_error(values: list[object]) -> str | None: return None +def _product_token_error(value: object) -> str | None: + """Validate only that a Taobao product token was provided. + + Taobao's command is intentionally treated as opaque text. It may contain + characters that are not valid in a URL, so no HTTP/HTTPS parsing should be + performed here. + """ + if not str(value or "").strip(): + return "商品淘口令为必填项" + return None + + def _generation_options_error(payload: dict, include_answers: bool = True) -> str | None: # 语言风格、目标人群、场景等生成偏好均可留空,仅限制生成条数必填。 required = [("question_count", "提问生成条数")] @@ -127,9 +139,10 @@ PHONE_CODES: dict[str, dict] = {} DINGTALK_STATES: dict[str, float] = {} -ROLE_LABELS = {"super_admin": "超级管理员", "operator": "普通运营"} +ROLE_LABELS = {"super_admin": "超级管理员", "dept_manager": "部门主管", "operator": "普通运营"} ROLE_PERMISSIONS = { "super_admin": ["assistant", "library", "create", "board", "review", "users", "logs", "settings"], + "dept_manager": ["assistant", "library", "create", "board", "review"], "operator": ["assistant", "library", "create", "board", "review"], } @@ -142,6 +155,10 @@ def public_user(user: AdminUser) -> dict: "role_code": user.role_code, "role_label": ROLE_LABELS.get(user.role_code, user.role_code), "permissions": ROLE_PERMISSIONS.get(user.role_code, []), + "dingtalk_user_id": user.dingtalk_user_id, + "dingtalk_dept_id": user.dingtalk_dept_id, + "dingtalk_dept_name": user.dingtalk_dept_name, + "is_dept_manager": user.is_dept_manager, } @@ -166,6 +183,28 @@ def startup(): if "owner_id" not in columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN owner_id INTEGER NULL")) + if "public_token" not in columns: + with engine.begin() as connection: + connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN public_token VARCHAR(64) NULL")) + admin_user_columns = {column["name"] for column in inspect(engine).get_columns("admin_users")} + admin_user_migrations = { + "dingtalk_user_id": "VARCHAR(128) NULL", + "dingtalk_union_id": "VARCHAR(128) NULL", + "dingtalk_corp_id": "VARCHAR(128) NULL", + "dingtalk_dept_id": "VARCHAR(64) NULL", + "dingtalk_dept_name": "VARCHAR(255) NULL", + "dingtalk_dept_path": "VARCHAR(1000) NULL", + "dingtalk_title": "VARCHAR(255) NULL", + "is_dept_manager": "BOOLEAN NOT NULL DEFAULT 0", + } + for column_name, column_type in admin_user_migrations.items(): + if column_name not in admin_user_columns: + with engine.begin() as connection: + connection.execute(text(f"ALTER TABLE admin_users ADD COLUMN {column_name} {column_type}")) + answer_submission_columns = {column["name"] for column in inspect(engine).get_columns("answer_submissions")} + if "owner_id" not in answer_submission_columns: + with engine.begin() as connection: + connection.execute(text("ALTER TABLE answer_submissions ADD COLUMN owner_id INTEGER NULL")) question_columns = {column["name"] for column in inspect(engine).get_columns("answer_recognized_questions")} if "operator_image_screenshot_id" not in question_columns: with engine.begin() as connection: @@ -173,8 +212,19 @@ def startup(): if "operator_image_screenshot_ids" not in question_columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE answer_recognized_questions ADD COLUMN operator_image_screenshot_ids TEXT NULL")) - db = next(get_db()) + db = SessionLocal() try: + legacy_tasks = db.scalars( + select(AskTask).where(AskTask.public_token.is_(None), AskTask.status != "草稿") + ).all() + for task in legacy_tasks: + task.public_token = secrets.token_urlsafe(24) + if legacy_tasks: + db.commit() + index_names = {index["name"] for index in inspect(engine).get_indexes("ask_tasks")} + if "ix_ask_tasks_public_token" not in index_names: + with engine.begin() as connection: + connection.execute(text("CREATE UNIQUE INDEX ix_ask_tasks_public_token ON ask_tasks (public_token)")) seed_users(db) finally: db.close() @@ -188,6 +238,12 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> AdminUser: user = db.get(AdminUser, int(payload["sub"])) if not user or not user.is_active: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不可用") + # Authentication only needs the loaded user. End the implicit SELECT + # transaction here so a subsequent long-running route operation (LLM, + # file I/O, or an external API call) does not keep a pool connection + # checked out for the whole request. + db.expunge(user) + db.rollback() return user @@ -225,6 +281,8 @@ def _answer_user_from_token(token: str, db: Session) -> AnswerUser: user = db.scalar(select(AnswerUser).where(AnswerUser.auth_token == token)) if not user: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="回答用户登录已失效") + db.expunge(user) + db.rollback() return user @@ -322,6 +380,82 @@ def _dingtalk_user(access_token: str) -> dict: raise HTTPException(status_code=502, detail="无法读取钉钉用户信息,请稍后重试") from exc +def _dingtalk_oapi_post(path: str, access_token: str, payload: dict) -> dict: + request = urllib.request.Request( + "https://oapi.dingtalk.com" + path + "?" + urllib.parse.urlencode({"access_token": access_token}), + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + return json.loads(response.read().decode("utf-8")) + except Exception: + # Organization data is supplemental to authentication. A temporary + # directory API failure must not prevent an otherwise valid SSO login. + return {} + + +def _dingtalk_org_profile(profile: dict, access_token: str | None) -> dict: + """Best-effort enrichment of a DingTalk profile with department data.""" + data = dict(profile or {}) + data["_org_data_available"] = False + user_id = str(data.get("userid") or data.get("userId") or data.get("userid") or "").strip() + if access_token and user_id: + detail_response = _dingtalk_oapi_post( + "/topapi/v2/user/get", + access_token, + {"userid": user_id, "language": "zh_CN"}, + ) + detail = detail_response.get("result") or detail_response.get("data") or {} + if isinstance(detail, dict) and detail: + data.update(detail) + data.setdefault("userid", user_id) + data["_org_data_available"] = True + + raw_dept_ids = data.get("dept_id_list") or data.get("deptIdList") or data.get("dept_ids") or [] + if isinstance(raw_dept_ids, (str, int)): + raw_dept_ids = [raw_dept_ids] + dept_ids = [str(value).strip() for value in raw_dept_ids if str(value).strip()] + data["_primary_dept_id"] = dept_ids[0] if dept_ids else "" + data["_dept_name"] = "" + data["_dept_path"] = "" + if access_token and dept_ids: + current_id = dept_ids[0] + chain = [] + visited = set() + while current_id and current_id not in visited and len(chain) < 20: + visited.add(current_id) + result = _dingtalk_oapi_post( + "/topapi/v2/department/get", + access_token, + {"dept_id": int(current_id) if current_id.isdigit() else current_id, "language": "zh_CN"}, + ).get("result") or {} + if not isinstance(result, dict): + break + chain.append((current_id, str(result.get("name") or "").strip())) + parent_id = result.get("parent_id") or result.get("parentId") + current_id = str(parent_id).strip() if parent_id not in (None, "", 0, "0") else "" + if chain: + chain.reverse() + data["_dept_name"] = chain[-1][1] or "" + data["_dept_path"] = "," + ",".join(item[0] for item in chain) + "," + + leader_info = data.get("leader_in_dept") or data.get("leaderInDept") or [] + is_manager = bool(data.get("is_dept_manager") or data.get("isDeptManager")) + if isinstance(leader_info, bool): + is_manager = is_manager or leader_info + elif isinstance(leader_info, dict): + is_manager = is_manager or bool(leader_info.get("is_leader") or leader_info.get("isLeader")) + elif isinstance(leader_info, list): + is_manager = is_manager or any( + isinstance(item, dict) and bool(item.get("is_leader") or item.get("isLeader")) + for item in leader_info + ) + data["_is_dept_manager"] = is_manager + return data + + def _dingtalk_workbench_user(code: str) -> dict: """Resolve a workbench JSAPI requestAuthCode code.""" token_request = urllib.request.Request( @@ -358,7 +492,11 @@ def _dingtalk_workbench_user(code: str) -> dict: raise HTTPException(status_code=502, detail=f"无法读取钉钉用户信息:{exc}") from exc if user_data.get("errcode", 0) not in (0, None): raise HTTPException(status_code=502, detail=f"钉钉用户信息读取失败:{user_data.get('errmsg', '请检查应用权限')}") - return user_data.get("result") or user_data + result = user_data.get("result") or user_data + if isinstance(result, dict): + result = dict(result) + result["_dingtalk_access_token"] = access_token + return result def _dingtalk_create_session(profile: dict, db: Session, response: Response) -> AdminUser: @@ -370,16 +508,43 @@ def _dingtalk_create_session(profile: dict, db: Session, response: Response) -> username = f"dingtalk_{union_id}"[:64] user = db.scalar(select(AdminUser).where(AdminUser.username == username)) display_name = str(profile.get("nick") or profile.get("name") or "钉钉用户").strip()[:64] - role_code = "super_admin" if union_id in settings.dingtalk_super_admin_union_ids else "operator" + role_code = ( + "super_admin" if union_id in settings.dingtalk_super_admin_union_ids + else "dept_manager" if bool(profile.get("_is_dept_manager")) + else "operator" + ) + dingtalk_user_id = str(profile.get("userid") or profile.get("userId") or "").strip() or None + dept_id = str(profile.get("_primary_dept_id") or "").strip() or None + dept_name = str(profile.get("_dept_name") or profile.get("dept_name") or "").strip() or None + dept_path = str(profile.get("_dept_path") or "").strip() or None + is_dept_manager = bool(profile.get("_is_dept_manager")) if not user: - user = AdminUser(username=username, password_hash=hash_password(secrets.token_urlsafe(32)), display_name=display_name, role_code=role_code) + user = AdminUser(username=username, password_hash=hash_password(secrets.token_urlsafe(32)), display_name=display_name, role_code=role_code, + dingtalk_user_id=dingtalk_user_id, dingtalk_union_id=union_id, + dingtalk_corp_id=settings.dingtalk_corp_id or None, dingtalk_dept_id=dept_id, + dingtalk_dept_name=dept_name, dingtalk_dept_path=dept_path, + dingtalk_title=str(profile.get("title") or "").strip()[:255] or None, + is_dept_manager=is_dept_manager) db.add(user) elif not user.is_active: raise HTTPException(status_code=403, detail="运营账号已被停用") else: user.display_name = display_name or user.display_name + user.dingtalk_user_id = dingtalk_user_id or user.dingtalk_user_id + user.dingtalk_union_id = union_id or user.dingtalk_union_id + user.dingtalk_corp_id = settings.dingtalk_corp_id or user.dingtalk_corp_id + if profile.get("_org_data_available"): + user.dingtalk_dept_id = dept_id + user.dingtalk_dept_name = dept_name + user.dingtalk_dept_path = dept_path + user.dingtalk_title = str(profile.get("title") or "").strip()[:255] or user.dingtalk_title + user.is_dept_manager = is_dept_manager if union_id in settings.dingtalk_super_admin_union_ids: user.role_code = "super_admin" + elif is_dept_manager: + user.role_code = "dept_manager" + elif user.role_code == "dept_manager" and profile.get("_org_data_available"): + user.role_code = "operator" user.last_login_at = datetime.utcnow() db.flush() db.add(AdminLoginLog(user_id=user.id, username=username, success=True)) @@ -399,11 +564,17 @@ def _dingtalk_login(code: str, db: Session, response: Response) -> AdminUser: if not access_token: raise HTTPException(status_code=502, detail="钉钉未返回有效授权令牌") profile = _dingtalk_user(access_token) + profile = _dingtalk_org_profile(profile, access_token) return _dingtalk_create_session(profile, db, response) def _can_access_ask_task(task: AskTask, user: AdminUser) -> bool: - return user.role_code == "super_admin" or task.owner_id == user.id + if user.role_code == "super_admin" or task.owner_id == user.id: + return True + if user.role_code != "dept_manager" or not user.dingtalk_dept_id or not task.owner: + return False + owner_path = task.owner.dingtalk_dept_path or "" + return f",{user.dingtalk_dept_id}," in owner_path def _require_ask_task_access(task: AskTask | None, user: AdminUser) -> AskTask: @@ -413,14 +584,64 @@ def _require_ask_task_access(task: AskTask | None, user: AdminUser) -> AskTask: def _ask_task_owner_clause(user: AdminUser): - return True if user.role_code == "super_admin" else AskTask.owner_id == user.id + if user.role_code == "super_admin": + return True + if user.role_code != "dept_manager" or not user.dingtalk_dept_id: + return AskTask.owner_id == user.id + descendant_owners = select(AdminUser.id).where( + or_( + AdminUser.id == user.id, + AdminUser.dingtalk_dept_path.like(f"%,{user.dingtalk_dept_id},%"), + AdminUser.dingtalk_dept_id == user.dingtalk_dept_id, + ) + ) + return AskTask.owner_id.in_(descendant_owners) -def _published_ask_task(task_no: str, db: Session, *, lock: bool = False) -> AskTask: - if task_no == "latest": - statement = select(AskTask).where(AskTask.status == "已发布").order_by(AskTask.created_at.desc()) - else: - statement = select(AskTask).where(AskTask.task_no == task_no, AskTask.status == "已发布") +def _can_access_answer_submission(record: AnswerSubmission | None, user: AdminUser) -> bool: + if not record: + return False + if user.role_code == "super_admin" or record.owner_id == user.id: + return True + if record.owner_id is None: + return user.role_code == "dept_manager" + if user.role_code != "dept_manager" or not user.dingtalk_dept_id or not record.owner: + return False + return f",{user.dingtalk_dept_id}," in (record.owner.dingtalk_dept_path or "") + + +def _answer_owner_clause(user: AdminUser): + if user.role_code == "super_admin": + return True + if user.role_code != "dept_manager" or not user.dingtalk_dept_id: + return AnswerSubmission.owner_id == user.id + descendant_owners = select(AdminUser.id).where( + or_( + AdminUser.id == user.id, + AdminUser.dingtalk_dept_path.like(f"%,{user.dingtalk_dept_id},%"), + AdminUser.dingtalk_dept_id == user.dingtalk_dept_id, + ) + ) + return or_(AnswerSubmission.owner_id.is_(None), AnswerSubmission.owner_id.in_(descendant_owners)) + + + + +def _new_ask_public_token(db: Session) -> str: + while True: + token = secrets.token_urlsafe(24) + if not db.scalar(select(AskTask.id).where(AskTask.public_token == token)): + return token + + +def _published_ask_task(public_token: str, db: Session, *, lock: bool = False) -> AskTask: + statement = select(AskTask).where( + # New links use a random public token. Keep the task number as a + # read-compatible alias for links that were already sent before this + # change; all newly generated links use public_token. + (AskTask.public_token == public_token) | (AskTask.task_no == public_token), + AskTask.status == "已发布", + ) # Serialize allocation for the same task. This prevents two users who # open the link at the same moment from receiving the same question. if lock: @@ -431,15 +652,16 @@ def _published_ask_task(task_no: str, db: Session, *, lock: bool = False) -> Ask return task -def _ask_assignment_data(assignment: AskAssignment, include_images: bool = True) -> dict: +def _ask_assignment_data(assignment: AskAssignment, include_images: bool = True, include_refund_images: bool = False) -> dict: items = sorted(assignment.items, key=lambda item: item.question.sequence_no) used_ids = {item.question_id for item in assignment.used_questions} # List endpoints must not touch the screenshot relationship. The image # BLOBs are large and loading them for every row was the main cause of the # multi-second task-board/review-center delay. - screenshots = list(assignment.screenshots) if include_images else [] + screenshots = list(assignment.screenshots) if include_images or (include_refund_images and assignment.status == "已完成") else [] question_screenshots = [item for item in screenshots if item.stage == "question"] payment_screenshots = [item for item in screenshots if item.stage == "payment"] + refund_screenshots = [item for item in screenshots if item.stage == "refund"] latest_review = ( max(assignment.review_decisions, key=lambda item: item.created_at) if include_images and assignment.review_decisions @@ -467,6 +689,8 @@ def _ask_assignment_data(assignment: AskAssignment, include_images: bool = True) "updated_at": assignment.updated_at.isoformat(), "question_screenshots": [{"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"} for image in question_screenshots] if include_images else [], "payment_screenshots": [{"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"} for image in payment_screenshots] if include_images else [], + "refund_screenshots": [{"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"} for image in refund_screenshots] if include_images or include_refund_images else [], + "refund_completed": assignment.status == "已完成" or bool(refund_screenshots), "latest_review": ({ "approved": latest_review.approved, "reason_code": latest_review.reason_code, @@ -483,10 +707,10 @@ def _ask_assignment_data(assignment: AskAssignment, include_images: bool = True) } -def _ask_batch_data(batch: AskAssignmentBatch, include_images: bool = True) -> dict: +def _ask_batch_data(batch: AskAssignmentBatch, include_images: bool = True, include_refund_images: bool = False) -> dict: """Serialize one independent submission batch for review/history views.""" assignment = batch.assignment - parent = _ask_assignment_data(assignment, include_images=False) + parent = _ask_assignment_data(assignment, include_images=False, include_refund_images=include_refund_images) try: selected_ids = {int(value) for value in (batch.selected_question_ids or "").split(",") if value} except ValueError: @@ -497,9 +721,10 @@ def _ask_batch_data(batch: AskAssignmentBatch, include_images: bool = True) -> d if include_images and batch.review_decisions else None ) - screenshots = list(batch.screenshots) if include_images else [] + screenshots = list(batch.screenshots) if include_images or (include_refund_images and batch.status == "paid") else [] question_images = [item for item in screenshots if item.stage == "question"] payment_images = [item for item in screenshots if item.stage == "payment"] + refund_images = [item for item in screenshots if item.stage == "refund"] questions = [] for item in sorted(assignment.items, key=lambda value: value.question.sequence_no): questions.append({ @@ -516,7 +741,7 @@ def _ask_batch_data(batch: AskAssignmentBatch, include_images: bool = True) -> d "parent_assignment_id": assignment.id, # A paid batch remains an approved audit record in the review center; # payout completion is represented separately on the task board. - "status": {"pending": "待审核", "approved": "已通过", "paid": "已通过", "rejected": "未通过"}.get(batch.status, batch.status), + "status": {"pending": "待审核", "approved": "已通过", "paid": "已完成", "rejected": "未通过"}.get(batch.status, batch.status), "created_at": batch.created_at.isoformat(), "updated_at": batch.updated_at.isoformat(), "questions": questions, @@ -528,6 +753,11 @@ def _ask_batch_data(batch: AskAssignmentBatch, include_images: bool = True) -> d {"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"} for image in payment_images ] if include_images else [], + "refund_screenshots": [ + {"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"} + for image in refund_images + ] if include_images or include_refund_images else [], + "refund_completed": batch.status == "paid" or bool(refund_images), "latest_review": ({ "approved": latest_review.approved, "reason_code": latest_review.reason_code, @@ -553,14 +783,15 @@ def _image_url(image: AnswerScreenshot) -> str: return f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}" -def _submission_data(record: AnswerSubmission, include_images: bool = True) -> dict: +def _submission_data(record: AnswerSubmission, include_images: bool = True, include_refund_images: bool = False) -> dict: # List endpoints deliberately omit screenshot BLOBs. Do not even touch # the lazy relationship in that mode, otherwise SQLAlchemy fetches every # image from remote MySQL before returning the lightweight response. - screenshots = list(record.screenshots) if include_images else [] + screenshots = list(record.screenshots) if include_images or (include_refund_images and record.status == "已完成") else [] invited = [x for x in screenshots if x.stage == "invited"] completion = [x for x in screenshots if x.stage == "completion"] payment = [x for x in screenshots if x.stage == "payment"] + refund = [x for x in screenshots if x.stage == "refund"] images = {x.id: x for x in screenshots} latest_review = max(record.review_decisions, key=lambda value: value.created_at) if record.review_decisions else None return { @@ -574,6 +805,8 @@ def _submission_data(record: AnswerSubmission, include_images: bool = True) -> d "invited_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x)} for x in invited] if include_images else [], "completion_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x)} for x in completion] if include_images else [], "payment_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x)} for x in payment] if include_images else [], + "refund_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x)} for x in refund] if include_images or include_refund_images else [], + "refund_completed": record.status == "已完成" or bool(refund), "latest_review": ({ "approved": latest_review.approved, "reason_code": latest_review.reason_code, @@ -607,6 +840,8 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> d "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], + "refund_screenshots": [], + "refund_completed": record.status == "已完成", "latest_review": None, "questions": [], } @@ -632,6 +867,7 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> d "can_delete": True, "delete_endpoint": f"/api/admin/task-board/{record.id}", "refund_endpoint": f"/api/admin/task-board/{record.id}/confirm-refund" if record.status == "已通过" else None, + "refund_screenshot_count": len(summary.get("refund_screenshots") or []) or (1 if record.status == "已完成" else 0), "assigned_users": [{"user_id": record.user.public_id, "phone": record.user.phone, "status": status_labels.get(record.status, record.status)}], "selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": q.answer_text, "product_title": q.product_title} for q in selected] if include_images else []), } @@ -645,6 +881,10 @@ def _ask_assignment_child_data(assignment: AskAssignment) -> dict: } used_ids = {item.question_id for item in assignment.used_questions} latest_batch = max(assignment.batches, key=lambda value: value.updated_at or value.created_at, default=None) + # A batch can only enter ``paid`` through the refund endpoint, which now + # requires a refund screenshot. Keep the board list lightweight and do + # not load image BLOBs just to render this indicator. + refund_proof_uploaded = bool(latest_batch and latest_batch.status == "paid") or assignment.status == "已完成" batch_selected_ids = set() if latest_batch and latest_batch.selected_question_ids: batch_selected_ids = { @@ -706,6 +946,9 @@ def _ask_assignment_child_data(assignment: AskAssignment) -> dict: "question_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], + "refund_screenshots": [], + "refund_proof_uploaded": refund_proof_uploaded, + "refund_proof_count": 1 if refund_proof_uploaded else 0, "latest_review": None, } @@ -721,18 +964,31 @@ def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: assigned_count = len(task.assignments) completed_count = sum(len(assignment.used_questions) for assignment in task.assignments) pending_count = sum(child["pending_review_count"] for child in claimed_tasks) + fully_claimed = bool(selected_questions) and completed_count >= len(selected_questions) + if task.status == "已撤销": + status_label = "已撤销" + elif task.status == "已完成" or fully_claimed: + status_label = "已完成" + else: + status_label = "进行中" return { "id": task.id, "board_key": f"ask-task-{task.id}", "record_kind": "ask_task", "task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务", + "public_token": task.public_token, "product_name": task.product_name, "product_link": task.product_link, "status": task.status, "claim_questions_per_user": task.claim_questions_per_user or 5, - "status_label": {"已发布": "进行中", "已撤销": "已撤销", "已完成": "已完成"}.get(task.status, task.status), + "status_label": status_label, "qa_count": len(selected_questions), "claimed_count": assigned_count, "submitted_count": completed_count, "pending_review_count": pending_count, "completed_count": completed_count, "total_count": len(selected_questions), + "refund_screenshot_count": sum(child.get("refund_proof_count", 0) for child in claimed_tasks), "creator_name": task.owner.display_name if task.owner else "运营后台", "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(), "can_cancel": task.status == "已发布", "can_complete": task.status == "已发布", "can_delete": True, "delete_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}", - "user_id": "--", "phone": "", "questions": [], + # A system-dispatched task is created by an operator rather than an + # answer user, so show the creator in the user-id column instead of + # the ambiguous placeholder "--". Claimed child rows still expose + # the actual answer user's public ID. + "user_id": task.owner.display_name if task.owner else "运营后台", "phone": "", "questions": [], "selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": "", "product_title": task.product_name} for q in selected_questions] if include_questions else []), "assigned_users": [child["assigned_users"][0] for child in claimed_tasks if child.get("assigned_users")], "claimed_tasks": claimed_tasks, @@ -757,7 +1013,7 @@ def _ask_review_board_data(record: AskAssignmentBatch | AskAssignment, include_i is_batch = isinstance(record, AskAssignmentBatch) assignment = record.assignment if is_batch else record if include_images: - data = _ask_batch_data(record, include_images=True) if is_batch else _ask_assignment_data(record, include_images=True) + data = _ask_batch_data(record, include_images=True, include_refund_images=True) if is_batch else _ask_assignment_data(record, include_images=True, include_refund_images=True) selected = [question for question in data["questions"] if question["selected"]] elif is_batch: selected_ids = {int(value) for value in (record.selected_question_ids or "").split(",") if value} @@ -778,7 +1034,7 @@ def _ask_review_board_data(record: AskAssignmentBatch | AskAssignment, include_i "latest_review": None, } else: - data = _ask_assignment_data(record, include_images=False) + data = _ask_assignment_data(record, include_images=False, include_refund_images=True) selected = [question for question in data["questions"] if question["selected"]] selected_ids = {question["id"] for question in selected} data["questions"] = [] @@ -903,8 +1159,12 @@ def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)): # the current selection. Do not replace it with the previous submitted # batch or a refresh would appear to undo the user's choices. if latest_batch and assignment.status != "进行中": - return {"success": True, "data": _ask_batch_data(latest_batch)} - return {"success": True, "data": _ask_assignment_data(assignment)} + data = _ask_batch_data(latest_batch, include_images=False, include_refund_images=latest_batch.status == "paid") + db.rollback() + return {"success": True, "data": data} + data = _ask_assignment_data(assignment, include_images=False) + db.rollback() + return {"success": True, "data": data} @app.get("/api/ask/me/assignments") @@ -921,9 +1181,10 @@ def public_ask_assignments(token: str, db: Session = Depends(get_db)): for record in records: batches = sorted(record.batches, key=lambda value: value.created_at, reverse=True) if batches: - data.extend(_ask_batch_data(batch, include_images=False) for batch in batches) + data.extend(_ask_batch_data(batch, include_images=False, include_refund_images=batch.status == "paid") for batch in batches) else: - data.append(_ask_assignment_data(record, include_images=False)) + data.append(_ask_assignment_data(record, include_images=False, include_refund_images=record.status == "已完成")) + db.rollback() return {"success": True, "data": data} @@ -954,7 +1215,9 @@ def select_public_ask_questions(task_no: str, payload: dict, db: Session = Depen item.selected = item.question_id in selected_ids assignment.status = "进行中" db.commit(); db.refresh(assignment) - return {"success": True, "data": _ask_assignment_data(assignment)} + data = _ask_assignment_data(assignment, include_images=False) + db.rollback() + return {"success": True, "data": data} @app.post("/api/ask/tasks/{task_no}/submission") @@ -983,6 +1246,16 @@ async def submit_public_ask_task( if any(upload.content_type not in allowed for upload in [*files, payment_file]): raise HTTPException(status_code=400, detail="仅支持 JPG、PNG、WEBP 图片") selected_ids = [item.question_id for item in assignment.items if item.selected] + assignment_id = assignment.id + db.rollback() + question_uploads = [(upload.filename or "question", upload.content_type, await upload.read()) for upload in files] + payment_upload = (payment_file.filename or "payment", payment_file.content_type, await payment_file.read()) + assignment = db.get(AskAssignment, assignment_id) + if not assignment or assignment.status not in {"进行中", "未通过"}: + raise HTTPException(status_code=409, detail="当前任务不能提交审核") + selected_ids = [item.question_id for item in assignment.items if item.selected] + if not selected_ids: + raise HTTPException(status_code=409, detail="当前没有可提交的问题,请刷新后重试") batch = AskAssignmentBatch( assignment_id=assignment.id, status="pending", @@ -990,9 +1263,10 @@ async def submit_public_ask_task( ) db.add(batch) db.flush() - for upload in files: - db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="question", filename=upload.filename or "question", content_type=upload.content_type, image_data=await upload.read())) - db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="payment", filename=payment_file.filename or "payment", content_type=payment_file.content_type, image_data=await payment_file.read())) + for filename, content_type, image_data in question_uploads: + db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="question", filename=filename, content_type=content_type, image_data=image_data)) + filename, content_type, image_data = payment_upload + db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="payment", filename=filename, content_type=content_type, image_data=image_data)) assignment.status = "待审核" used_ids = {item.question_id for item in assignment.used_questions} for item in assignment.items: @@ -1001,7 +1275,9 @@ async def submit_public_ask_task( used_ids.add(item.question_id) assignment.updated_at = datetime.utcnow() db.commit(); db.refresh(batch); db.refresh(assignment) - return {"success": True, "data": _ask_batch_data(batch)} + data = _ask_batch_data(batch, include_images=False) + db.rollback() + return {"success": True, "data": data} @app.get("/api/admin/ask-completion-reviews") @@ -1118,7 +1394,7 @@ def answer_submission_detail(submission_id: int, token: str, db: Session = Depen record = db.get(AnswerSubmission, submission_id) if not record or record.user_id != user.id: raise HTTPException(status_code=404, detail="任务不存在") - return {"success": True, "data": _submission_data(record, include_images=True)} + return {"success": True, "data": _submission_data(record, include_images=True, include_refund_images=True)} def _process_answer_submission(submission_id: int): @@ -1129,14 +1405,34 @@ def _process_answer_submission(submission_id: int): if not record or record.status != "待筛选": return saved = [shot for shot in record.screenshots if shot.stage == "invited"] - extracted = extract_questions_from_images([(shot.image_data, shot.content_type) for shot in saved]) + # A SQLAlchemy Session keeps its transaction/connection until + # commit/rollback. Do not hold that connection while OCR/LLM calls + # (which can take up to the configured timeout) are running. + saved_payload = [(shot.image_data, shot.content_type) for shot in saved] + db.rollback() + extracted = extract_questions_from_images(saved_payload) if not extracted: extracted = [ {"product_title": "待识别商品", "question": "这款商品使用起来感觉怎么样?"}, {"product_title": "待识别商品", "question": "敏感肌可以使用这款商品吗?"}, {"product_title": "待识别商品", "question": "使用后的肤感和效果怎么样?"}, ][:max(1, min(3, len(saved)))] - for index, item in enumerate(extracted): + drafted = [ + { + "product_title": item["product_title"], + "question": item["question"], + "answer": generate_answer_for_question(item["product_title"], item["question"]), + } + for item in extracted + ] + # Re-read after the external calls so the write happens in a short, + # fresh transaction and does not overwrite a concurrent state change. + record = db.get(AnswerSubmission, submission_id) + if not record or record.status != "待筛选": + db.rollback() + return + saved = [shot for shot in record.screenshots if shot.stage == "invited"] + for index, item in enumerate(drafted): product, question = item["product_title"], item["question"] db.add(AnswerRecognizedQuestion( submission_id=record.id, @@ -1145,7 +1441,7 @@ def _process_answer_submission(submission_id: int): sequence_no=index + 1, product_title=product, question_text=question, - answer_text=generate_answer_for_question(product, question), + answer_text=item["answer"], answer_status="已生成", )) record.updated_at = datetime.utcnow() @@ -1154,6 +1450,8 @@ def _process_answer_submission(submission_id: int): db.rollback() # Keep the submission visible to operators even if OCR/model is unavailable. try: + record = db.get(AnswerSubmission, submission_id) + db.rollback() record = db.get(AnswerSubmission, submission_id) saved = [shot for shot in (record.screenshots if record else []) if shot.stage == "invited"] if record and not record.questions and saved: @@ -1187,17 +1485,21 @@ async def answer_create_submission( db: Session = Depends(get_db), ): user = _answer_user_from_token(token, db) + user_id = user.id + db.rollback() if not files or len(files) > 10: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最多上传10张截图") - # 先落库并立即返回;OCR/回答生成放到响应后的后台任务执行。 - record = AnswerSubmission(submission_no=f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", user_id=user.id, status="待筛选") - db.add(record); db.flush() - saved = [] + allowed = {"image/jpeg", "image/png", "image/webp"} + uploaded = [] for upload in files: - if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: - db.rollback(); raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片") - shot = AnswerScreenshot(submission_id=record.id, stage="invited", filename=upload.filename or "screenshot", content_type=upload.content_type, image_data=await upload.read()) - db.add(shot); db.flush(); saved.append(shot) + if upload.content_type not in allowed: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片") + uploaded.append((upload.filename or "screenshot", upload.content_type, await upload.read())) + # Persist only after all asynchronous file reads have completed. + record = AnswerSubmission(submission_no=f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", user_id=user_id, status="待筛选") + db.add(record); db.flush() + for filename, content_type, image_data in uploaded: + db.add(AnswerScreenshot(submission_id=record.id, stage="invited", filename=filename, content_type=content_type, image_data=image_data)) db.commit(); db.refresh(record) background_tasks.add_task(_process_answer_submission, record.id) # 先返回轻量结果,图片由后续状态查询按需加载。 @@ -1232,21 +1534,29 @@ async def upload_answer_operator_images( raise HTTPException(status_code=404, detail="回答问题不存在或已经发布") if not files or len(files) > 3: raise HTTPException(status_code=400, detail="每道问题最多上传3张运营指定图片") + record_id = record.id + question_id_value = question.id + db.rollback() + uploaded = [] + for file in files: + if file.content_type not in {"image/jpeg", "image/png", "image/webp"}: + raise HTTPException(status_code=400, detail="运营指定图片仅支持 JPG、PNG、WEBP") + image_data = await file.read() + if not image_data: + raise HTTPException(status_code=400, detail="不能上传空图片") + uploaded.append((file.filename or "operator-image", file.content_type, image_data)) + question = db.get(AnswerRecognizedQuestion, question_id_value) + record = db.get(AnswerSubmission, record_id) + if not question or not record or record.status not in {"待筛选", "待运营派发", "已派发"}: + raise HTTPException(status_code=404, detail="回答问题不存在或已经发布") old_ids = [value for value in (question.operator_image_screenshot_ids or "").split(",") if value.strip().isdigit()] for old_id in old_ids: old = db.get(AnswerScreenshot, int(old_id)) if old: db.delete(old) new_ids = [] - for file in files: - if file.content_type not in {"image/jpeg", "image/png", "image/webp"}: - db.rollback() - raise HTTPException(status_code=400, detail="运营指定图片仅支持 JPG、PNG、WEBP") - image_data = await file.read() - if not image_data: - db.rollback() - raise HTTPException(status_code=400, detail="不能上传空图片") - image = AnswerScreenshot(submission_id=record.id, stage="operator", filename=file.filename or "operator-image", content_type=file.content_type, image_data=image_data) + for filename, content_type, image_data in uploaded: + image = AnswerScreenshot(submission_id=record.id, stage="operator", filename=filename, content_type=content_type, image_data=image_data) db.add(image); db.flush(); new_ids.append(str(image.id)) question.operator_image_screenshot_ids = ",".join(new_ids) question.operator_image_screenshot_id = int(new_ids[0]) if new_ids else None @@ -1271,7 +1581,9 @@ def reject_answer_task_draft(submission_id: int, _: AdminUser = Depends(current_ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): product_link = str(payload.get("product_link", "")).strip() product_name = str(payload.get("product_name", "")).strip() - if not product_link: raise HTTPException(status_code=400, detail="商品链接为必填项") + product_token_error = _product_token_error(product_link) + if product_token_error: + raise HTTPException(status_code=400, detail=product_token_error) intent_error = _product_intent_error([product_name, payload.get("category"), payload.get("benefits"), payload.get("selling_points")]) if intent_error: raise HTTPException(status_code=400, detail=intent_error) @@ -1303,6 +1615,29 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db _require_ask_task_access(task, user) if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能重新生成") + existing_id = task.id if task else None + # Release the authentication/existence-check connection before any LLM + # request. The task is reloaded below before writing the result. + db.rollback() + questions = payload.get("questions") if isinstance(payload.get("questions"), list) else [] + reference_text = str(payload.get("reference_qa", ""))[:20000] + if not questions: + questions = generate_questions(payload, count, reference_text) + # 自定义问题优先,不足的数量用候选问题补齐,保证返回数量与运营填写值一致。 + generated: list[str] = [] + for value in questions: + question = str(value).strip() + if question and question not in generated: + generated.append(question) + if len(generated) == count: + break + if len(generated) < count: + generated.extend(generate_questions(payload, count - len(generated), reference_text)) + if existing_id: + task = db.get(AskTask, existing_id) + _require_ask_task_access(task, user) + if task.status != "草稿": + raise HTTPException(status_code=409, detail="已发布任务不能重新生成") task.product_link = product_link task.product_name = product_name or "待补充商品" task.category = str(payload.get("category", "")) @@ -1319,20 +1654,6 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db else: task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", owner_id=user.id, product_link=product_link, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), claim_questions_per_user=claim_limit, status="草稿") db.add(task); db.flush() - questions = payload.get("questions") if isinstance(payload.get("questions"), list) else [] - reference_text = str(payload.get("reference_qa", ""))[:20000] - if not questions: - questions = generate_questions(payload, count, reference_text) - # 自定义问题优先,不足的数量用候选问题补齐,保证返回数量与运营填写值一致。 - generated: list[str] = [] - for value in questions: - question = str(value).strip() - if question and question not in generated: - generated.append(question) - if len(generated) == count: - break - if len(generated) < count: - generated.extend(generate_questions(payload, count - len(generated), reference_text)) for index, question in enumerate(generated): db.add(AskQuestion(task_id=task.id, sequence_no=index + 1, question_text=question, selected=True)) db.commit(); db.refresh(task) return {"success": True, "data": _ask_task_data(task)} @@ -1369,7 +1690,12 @@ def publish_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: task = db.get(AskTask, task_id) _require_ask_task_access(task, user) if task.status != "草稿": raise HTTPException(status_code=409, detail="任务已经发布") + product_token_error = _product_token_error(task.product_link) + if product_token_error: + raise HTTPException(status_code=400, detail=f"无法发布任务:{product_token_error}") if not any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问") + if not task.public_token: + task.public_token = _new_ask_public_token(db) task.status = "已发布"; db.commit(); db.refresh(task) return {"success": True, "data": _ask_task_data(task)} @@ -1400,20 +1726,33 @@ def complete_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: def update_answer_question(question_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): question = db.get(AnswerRecognizedQuestion, question_id) if not question: raise HTTPException(status_code=404, detail="问题不存在") - if "selected" in payload: question.selected = bool(payload["selected"]) - if str(payload.get("answer", "")).strip(): question.answer_text = str(payload["answer"]).strip() if payload.get("regenerate"): - question.answer_text = generate_answer_for_question(question.product_title, question.question_text) + product_title, question_text = question.product_title, question.question_text + selected = bool(payload["selected"]) if "selected" in payload else None + # Do not keep the ORM connection checked out during the model request. + db.rollback() + answer_text = generate_answer_for_question(product_title, question_text) + question = db.get(AnswerRecognizedQuestion, question_id) + if not question: + raise HTTPException(status_code=404, detail="问题不存在") + if selected is not None: + question.selected = selected + question.answer_text = answer_text + else: + if "selected" in payload: question.selected = bool(payload["selected"]) + if str(payload.get("answer", "")).strip(): question.answer_text = str(payload["answer"]).strip() db.commit(); db.refresh(question) return {"success": True, "data": {"id": question.id, "answer": question.answer_text, "selected": question.selected}} @app.post("/api/admin/answer-task-drafts/{submission_id}/publish") -def publish_answer_task(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def publish_answer_task(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record: raise HTTPException(status_code=404, detail="提交记录不存在") if record.status not in {"待筛选", "待运营派发", "已派发"}: raise HTTPException(status_code=409, detail="当前任务已经发布或不能发布") if not any(q.selected for q in record.questions): raise HTTPException(status_code=400, detail="请至少选择一个问题") + if record.owner_id is None: + record.owner_id = user.id record.status = "待用户完成"; db.commit() return {"success": True, "data": _submission_data(record, include_images=False)} @@ -1431,6 +1770,7 @@ def admin_task_board(user: AdminUser = Depends(current_user), db: Session = Depe noload(AnswerSubmission.review_decisions), ) .where(AnswerSubmission.status.in_(task_statuses)) + .where(_answer_owner_clause(user)) .order_by(AnswerSubmission.updated_at.desc()) ).unique().all() ask_statement = select(AskTask).options(selectinload(AskTask.owner)).where(AskTask.status.in_(["已发布", "已完成", "已撤销"]), _ask_task_owner_clause(user)).order_by(AskTask.updated_at.desc()) @@ -1503,7 +1843,7 @@ def admin_task_board_delete_ask_assignment(assignment_id: int, user: AdminUser = @app.post("/api/admin/task-board/ask-batches/{batch_id}/confirm-refund") -def confirm_ask_batch_refund(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile = File(...), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batch = db.get(AskAssignmentBatch, batch_id) if not batch or not _can_access_ask_task(batch.assignment.task, user): raise HTTPException(status_code=404, detail="待返款提问记录不存在") @@ -1511,14 +1851,20 @@ def confirm_ask_batch_refund(batch_id: int, user: AdminUser = Depends(current_us raise HTTPException(status_code=409, detail="该任务已经完成返款") if batch.status != "approved": raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") + if refund_file.content_type not in {"image/jpeg", "image/png", "image/webp"}: + raise HTTPException(status_code=400, detail="返款凭证仅支持 JPG、PNG、WEBP 图片") + refund_data = await refund_file.read() + if not refund_data: + raise HTTPException(status_code=400, detail="请上传返款凭证") + db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="refund", filename=refund_file.filename or "refund", content_type=refund_file.content_type, image_data=refund_data)) batch.status = "paid" batch.updated_at = datetime.utcnow() db.commit(); db.refresh(batch) - return {"success": True, "data": _ask_review_board_data(batch, include_images=False)} + return {"success": True, "data": _ask_review_board_data(batch, include_images=True)} @app.post("/api/admin/task-board/ask-assignments/{assignment_id}/confirm-refund") -def confirm_ask_assignment_refund(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +async def confirm_ask_assignment_refund(assignment_id: int, refund_file: UploadFile = File(...), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): assignment = db.get(AskAssignment, assignment_id) if not assignment or not _can_access_ask_task(assignment.task, user): raise HTTPException(status_code=404, detail="待返款提问记录不存在") @@ -1526,24 +1872,30 @@ def confirm_ask_assignment_refund(assignment_id: int, user: AdminUser = Depends( raise HTTPException(status_code=409, detail="该任务已经完成返款") if assignment.status != "已通过": raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") + if refund_file.content_type not in {"image/jpeg", "image/png", "image/webp"}: + raise HTTPException(status_code=400, detail="返款凭证仅支持 JPG、PNG、WEBP 图片") + refund_data = await refund_file.read() + if not refund_data: + raise HTTPException(status_code=400, detail="请上传返款凭证") + db.add(AskAssignmentScreenshot(assignment_id=assignment.id, stage="refund", filename=refund_file.filename or "refund", content_type=refund_file.content_type, image_data=refund_data)) assignment.status = "已完成" assignment.updated_at = datetime.utcnow() db.commit(); db.refresh(assignment) - return {"success": True, "data": _ask_review_board_data(assignment, include_images=False)} + return {"success": True, "data": _ask_review_board_data(assignment, include_images=True)} @app.get("/api/admin/task-board/{submission_id}") -def admin_task_board_detail(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def admin_task_board_detail(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) - if not record or record.status not in {"待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"}: + if not record or record.status not in {"待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"} or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="任务不存在") return {"success": True, "data": _task_board_data(record)} @app.delete("/api/admin/task-board/{submission_id}") -def admin_task_board_delete_submission(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def admin_task_board_delete_submission(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) - if not record: + if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="任务不存在") db.delete(record) db.commit() @@ -1551,23 +1903,29 @@ def admin_task_board_delete_submission(submission_id: int, _: AdminUser = Depend @app.post("/api/admin/task-board/{submission_id}/confirm-refund") -def confirm_answer_refund(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +async def confirm_answer_refund(submission_id: int, refund_file: UploadFile = File(...), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) - if not record: + if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="待返款回答记录不存在") if record.status == "已完成": raise HTTPException(status_code=409, detail="该任务已经完成返款") if record.status != "已通过": raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") + if refund_file.content_type not in {"image/jpeg", "image/png", "image/webp"}: + raise HTTPException(status_code=400, detail="返款凭证仅支持 JPG、PNG、WEBP 图片") + refund_data = await refund_file.read() + if not refund_data: + raise HTTPException(status_code=400, detail="请上传返款凭证") + db.add(AnswerScreenshot(submission_id=record.id, stage="refund", filename=refund_file.filename or "refund", content_type=refund_file.content_type, image_data=refund_data)) record.status = "已完成" db.commit(); db.refresh(record) - return {"success": True, "data": _task_board_data(record, include_images=False)} + return {"success": True, "data": _task_board_data(record, include_images=True)} @app.post("/api/admin/task-board/{submission_id}/cancel") -def admin_task_board_cancel(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def admin_task_board_cancel(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) - if not record: + if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="任务不存在") if record.status == "已撤销": raise HTTPException(status_code=409, detail="任务已经撤销") @@ -1584,46 +1942,55 @@ def admin_task_board_cancel(submission_id: int, _: AdminUser = Depends(current_u async def answer_completion(submission_id: int, token: str = Form(...), files: list[UploadFile] = File(...), payment_files: list[UploadFile] = File(default=[]), db: Session = Depends(get_db)): user = _answer_user_from_token(token, db); record = db.get(AnswerSubmission, submission_id) if not record or record.user_id != user.id: raise HTTPException(status_code=404, detail="任务不存在") - if record.status not in ("待用户完成", "未通过"): raise HTTPException(status_code=400, detail="当前任务不能提交完成截图") + record_status = record.status + if record_status not in ("待用户完成", "未通过"): raise HTTPException(status_code=400, detail="当前任务不能提交完成截图") if not files or len(files) > 10: raise HTTPException(status_code=400, detail="最多上传10张截图") if not payment_files or len(payment_files) > 1: raise HTTPException(status_code=400, detail="请上传收款二维码后再提交") - if record.status == "未通过": + db.rollback() + uploaded = [] + for upload in files: + if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: raise HTTPException(status_code=400, detail="仅支持图片") + uploaded.append(("completion", upload.filename or "completion", upload.content_type, await upload.read())) + for upload in payment_files[:1]: + if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: raise HTTPException(status_code=400, detail="收款码仅支持图片") + uploaded.append(("payment", upload.filename or "payment", upload.content_type, await upload.read())) + record = db.get(AnswerSubmission, submission_id) + if not record or record.user_id != user.id or record.status != record_status: + raise HTTPException(status_code=409, detail="任务状态已发生变化,请刷新后重试") + if record_status == "未通过": for old_screenshot in list(record.screenshots): if old_screenshot.stage in {"completion", "payment"}: db.delete(old_screenshot) - for upload in files: - if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: raise HTTPException(status_code=400, detail="仅支持图片") - db.add(AnswerScreenshot(submission_id=record.id, stage="completion", filename=upload.filename or "completion", content_type=upload.content_type, image_data=await upload.read())) - for upload in payment_files[:1]: - if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: raise HTTPException(status_code=400, detail="收款码仅支持图片") - db.add(AnswerScreenshot(submission_id=record.id, stage="payment", filename=upload.filename or "payment", content_type=upload.content_type, image_data=await upload.read())) + for stage, filename, content_type, image_data in uploaded: + db.add(AnswerScreenshot(submission_id=record.id, stage=stage, filename=filename, content_type=content_type, image_data=image_data)) record.status = "待审核"; db.commit() # 只返回轻量状态,避免重新编码截图阻塞审核状态切换。 return {"success": True, "data": _submission_data(record, include_images=False)} @app.get("/api/admin/answer-completion-reviews") -def answer_completion_reviews(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def answer_completion_reviews(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): records = db.scalars( select(AnswerSubmission) .where(AnswerSubmission.status == "待审核") + .where(_answer_owner_clause(user)) .order_by(AnswerSubmission.updated_at.desc()) ).unique().all() return {"success": True, "data": [_answer_review_data(record, include_images=False) for record in records]} @app.get("/api/admin/answer-completion-reviews/{submission_id}") -def answer_completion_review_detail(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def answer_completion_review_detail(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) - if not record: + if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="任务不存在") return {"success": True, "data": _answer_review_data(record)} @app.post("/api/admin/answer-completion-reviews/{submission_id}/decision") -def answer_completion_decision(submission_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def answer_completion_decision(submission_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) - if not record: raise HTTPException(status_code=404, detail="任务不存在") + if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="任务不存在") if record.status != "待审核": raise HTTPException(status_code=409, detail="任务不在待审核状态") approved = bool(payload.get("approved")) reason_code = str(payload.get("reason_code", "")).strip() or None @@ -2053,6 +2420,8 @@ def dingtalk_workbench_login(payload: dict, response: Response, db: Session = De if not code: raise HTTPException(status_code=400, detail="钉钉免登授权码不能为空") profile = _dingtalk_workbench_user(code) + access_token = profile.pop("_dingtalk_access_token", None) + profile = _dingtalk_org_profile(profile, access_token) user = _dingtalk_create_session(profile, db, response) return {"success": True, "data": {"user": public_user(user)}} @@ -2112,6 +2481,9 @@ def users(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get "display_name": user.display_name, "role_code": user.role_code, "role_label": ROLE_LABELS.get(user.role_code, user.role_code), + "dingtalk_dept_id": user.dingtalk_dept_id, + "dingtalk_dept_name": user.dingtalk_dept_name, + "is_dept_manager": user.is_dept_manager, "is_active": user.is_active, "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None, } diff --git a/backend/app/models.py b/backend/app/models.py index 24f9d4d..9165e7b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -14,6 +14,14 @@ class AdminUser(Base): password_hash: Mapped[str] = mapped_column(String(255)) display_name: Mapped[str] = mapped_column(String(64)) role_code: Mapped[str] = mapped_column(String(32), default="operator", index=True) + dingtalk_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + dingtalk_union_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + dingtalk_corp_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + dingtalk_dept_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + dingtalk_dept_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + dingtalk_dept_path: Mapped[str | None] = mapped_column(String(1000), nullable=True) + dingtalk_title: Mapped[str | None] = mapped_column(String(255), nullable=True) + is_dept_manager: Mapped[bool] = mapped_column(Boolean, default=False, index=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) @@ -73,12 +81,14 @@ class AnswerSubmission(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) submission_no: Mapped[str] = mapped_column(String(40), unique=True, index=True) user_id: Mapped[int] = mapped_column(ForeignKey("answer_users.id"), index=True) + owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True) # 用户上传受邀截图后立即进入运营筛选队列。 status: Mapped[str] = mapped_column(String(32), default="待筛选", index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) user: Mapped[AnswerUser] = relationship(back_populates="submissions", lazy="selectin") + owner: Mapped[AdminUser | None] = relationship() questions: Mapped[list["AnswerRecognizedQuestion"]] = relationship(back_populates="submission", cascade="all, delete-orphan", lazy="selectin") screenshots: Mapped[list["AnswerScreenshot"]] = relationship(back_populates="submission", cascade="all, delete-orphan") review_decisions: Mapped[list["AnswerReviewDecision"]] = relationship(back_populates="submission", cascade="all, delete-orphan", lazy="selectin") @@ -137,6 +147,7 @@ class AskTask(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) task_no: Mapped[str] = mapped_column(String(40), unique=True, index=True) + public_token: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True) owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True) product_link: Mapped[str] = mapped_column(String(1000)) product_name: Mapped[str] = mapped_column(String(255)) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 35f85a3..e488582 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -16,13 +16,13 @@ class AdminUserCreate(BaseModel): username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$") password: str = Field(min_length=8, max_length=128) display_name: str = Field(min_length=1, max_length=64) - role_code: str = Field(default="operator", pattern=r"^(super_admin|operator)$") + role_code: str = Field(default="operator", pattern=r"^(super_admin|dept_manager|operator)$") class AdminUserUpdate(BaseModel): password: str | None = Field(default=None, min_length=8, max_length=128) display_name: str | None = Field(default=None, min_length=1, max_length=64) - role_code: str | None = Field(default=None, pattern=r"^(super_admin|operator)$") + role_code: str | None = Field(default=None, pattern=r"^(super_admin|dept_manager|operator)$") is_active: bool | None = None diff --git a/backend/data/pool_audit_temp.db b/backend/data/pool_audit_temp.db new file mode 100644 index 0000000..86cff2b Binary files /dev/null and b/backend/data/pool_audit_temp.db differ diff --git a/backend/schema.sql b/backend/schema.sql index 6785902..1838697 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -34,3 +34,4 @@ CREATE TABLE IF NOT EXISTS admin_login_logs ( -- 提问任务归属运营账号;历史数据允许为空,超级管理员仍可查看全部任务。 -- 新环境建表时请将 owner_id 一并加入 ask_tasks;已有环境由应用启动时自动补列。 +-- public_token 是对外分享链接标识,不应使用 task_no 拼接用户链接。 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 5744597..bdd8e6e 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -170,13 +170,19 @@ def test_ask_task_publish_appears_on_task_board(): generated = client.post("/api/admin/ask-tasks/generate", json={"product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3}) assert generated.status_code == 200 task = generated.json()["data"] + assert task["public_token"] is None published = client.post(f"/api/admin/ask-tasks/{task['id']}/publish") assert published.status_code == 200 + public_token = published.json()["data"]["public_token"] + assert public_token + assert public_token != task["task_id"] + assert len(public_token) >= 24 board = client.get("/api/admin/task-board").json()["data"] created = next(item for item in board if item["task_id"] == task["task_id"]) assert created["task_type"] == "ask" assert created["task_type_label"] == "提问任务" assert created["qa_count"] == 3 + assert created["public_token"] == public_token def test_ask_task_generation_honors_question_count(): diff --git a/data/local_login_test.db b/data/local_login_test.db new file mode 100644 index 0000000..71017ec Binary files /dev/null and b/data/local_login_test.db differ diff --git a/data/local_test.db b/data/local_test.db new file mode 100644 index 0000000..1cd7320 Binary files /dev/null and b/data/local_test.db differ diff --git a/frontend/src/AnswerUserPortal.jsx b/frontend/src/AnswerUserPortal.jsx index 5f912e1..cc18e95 100644 --- a/frontend/src/AnswerUserPortal.jsx +++ b/frontend/src/AnswerUserPortal.jsx @@ -85,6 +85,7 @@ function AssignmentStage({ api, session, latest, reload, setError }) { function FinalStage({ latest, reload, busy, reopen, startNew }) { const paid = latest.status === '已完成' + const refundProofs = latest.refund_screenshots || [] const passed = ['已通过', '已完成'].includes(latest.status) const rejected = latest.status === '未通过' const cancelled = latest.status === '已撤销' @@ -110,6 +111,7 @@ function FinalStage({ latest, reload, busy, reopen, startNew }) {
{passed ? : cancelled ? '!' : }

{paid ? '已返款' : passed ? '审核已完成' : cancelled ? '任务已撤销' : '完成凭证审核中'} {STATUS_TEXT[latest.status]}

{paid ? '运营已完成本次任务返款,请留意您的收款账户。' : passed ? '运营已审核通过您提交的任务凭证,当前进入打款流程,请耐心等待打款。' : cancelled ? '运营已撤销本次任务,您无需继续提交。' : '您提交的回答完成截图正在审核,请耐心等待运营确认。'}

{passed &&
{paid ? '返款已完成' : '审核已完成,等待打款'}{paid ? '款项已由运营确认发放,请留意收款账户。' : '款项将按运营安排发放,请留意收款账户。'}
} + {paid && refundProofs.length > 0 &&

返款凭证

运营已上传
{refundProofs.map((image, index) =>
{`返款凭证${index
返款支付截图
)}
}
{latest.completion_screenshots?.length > 0 && } @@ -155,6 +157,7 @@ export default function AnswerUserPortalV2({ api }) { invited_screenshots: record.invited_screenshots?.length ? record.invited_screenshots : previous.invited_screenshots, completion_screenshots: record.completion_screenshots?.length ? record.completion_screenshots : previous.completion_screenshots, payment_screenshots: record.payment_screenshots?.length ? record.payment_screenshots : previous.payment_screenshots, + refund_screenshots: record.refund_screenshots?.length ? record.refund_screenshots : previous.refund_screenshots, } : record })) setOptimisticStatuses(current => { @@ -195,6 +198,7 @@ export default function AnswerUserPortalV2({ api }) { invited_screenshots: submitted.invited_screenshots?.length ? submitted.invited_screenshots : record.invited_screenshots, completion_screenshots: submitted.completion_screenshots || [], payment_screenshots: submitted.payment_screenshots || [], + refund_screenshots: submitted.refund_screenshots || [], } : record)) } const visibleLatest = newTaskMode ? null : latest diff --git a/frontend/src/AskUserPortal.jsx b/frontend/src/AskUserPortal.jsx index 013654b..ceeb99f 100644 --- a/frontend/src/AskUserPortal.jsx +++ b/frontend/src/AskUserPortal.jsx @@ -3,7 +3,6 @@ import { Check, ClipboardCheck, Copy, - ExternalLink, ImagePlus, ListChecks, LogOut, @@ -15,6 +14,7 @@ import { const SESSION_KEY = "ask_user_session"; const EXHAUSTED_MESSAGE = "本次任务已被领取完,请关注下一个任务!"; +const INVALID_TASK_MESSAGE = "提问任务链接无效、未发布或已结束"; const fileKey = (file) => `${file.name}-${file.size}-${file.lastModified}`; const maskPhone = (phone = "") => phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2"); @@ -24,6 +24,10 @@ function getTaskNo() { return segments[1] || "latest"; } +function isInvalidTaskError(message = "") { + return message.includes("提问任务链接无效") || message.includes("提问任务不存在"); +} + function PhoneLogin({ api, onLogin }) { const [phone, setPhone] = useState(""); const [code, setCode] = useState(""); @@ -140,6 +144,26 @@ function TaskExhausted({ session, onLogout }) { ); } +function InvalidTask({ message, onReload }) { + return ( +
+
+
+ 淘宝问答运营官方承接通道 + 官方认证 +
+
+ + 任务链接不可用 +

暂时无法读取任务

+

{message || INVALID_TASK_MESSAGE}。请向运营确认是否已成功发布,并重新打开最新任务链接。

+ +
+
+
+ ); +} + function Header({ session, assignment, @@ -370,6 +394,7 @@ function ConfirmStage({ setReviewOpen(false); onSubmitted(result.data); } catch (requestError) { + setReviewOpen(false); setError(requestError.message); } finally { setBusy(false); @@ -394,18 +419,15 @@ function ConfirmStage({
@@ -574,26 +596,48 @@ function ConfirmStage({ function AuditStatus({ assignment, reload, busy, onBackHome }) { const pending = assignment.status === "待审核"; - const passed = assignment.status === "已通过"; + const paid = assignment.status === "已完成"; + const passed = ["已通过", "已完成"].includes(assignment.status); const review = assignment.latest_review; + const refundProofs = assignment.refund_screenshots || []; return (
- {passed ? "✓" : assignment.status === "未通过" ? "!" : "⌁"} + {paid && refundProofs.length > 0 ? ( +
+ {refundProofs.map((image, index) => ( +
+ {`返款凭证${index +
返款凭证
+
+ ))} +
+ ) : ( + + {passed ? "✓" : assignment.status === "未通过" ? "!" : "⌁"} + + )} {assignment.status}

{pending ? "任务正在审核,请耐心等待。" - : passed + : paid + ? "返款已完成" + : passed ? "运营审核已通过" : "运营审核未通过"}

{pending ? "审核专员正在核验您的淘宝提问截图与发布有效性,核验通过后将自动更新状态。" - : passed + : paid + ? "运营已完成返款,以下为返款支付凭证,请留意您的收款账户。" + : passed ? "您提交的提问凭证已经核验通过,本次任务审核完成。" : "请根据运营反馈修改截图后重新提交审核。"}

@@ -743,6 +787,7 @@ export default function AskUserPortal({ api }) { const [notice, setNotice] = useState(""); const [claimEdit, setClaimEdit] = useState(false); const [exhausted, setExhausted] = useState(false); + const [invalidTask, setInvalidTask] = useState(""); async function loadAssignment(silent = false) { if (!session) return; @@ -752,8 +797,9 @@ export default function AskUserPortal({ api }) { const result = await api( `/api/ask/tasks/${encodeURIComponent(taskNo)}?token=${encodeURIComponent(session.token)}`, ); - setAssignment(result.data); + setAssignment(result.data); setExhausted(false); + setInvalidTask(""); // Keep the current view. In particular, polling a pending review must // not pull the user back to the audit screen after they chose to return // to the claim page. @@ -774,6 +820,11 @@ export default function AskUserPortal({ api }) { if (requestError.message === EXHAUSTED_MESSAGE) { setAssignment(null); setExhausted(true); + setInvalidTask(""); + setError(""); + } else if (isInvalidTaskError(requestError.message)) { + setAssignment(null); + setInvalidTask(requestError.message); setError(""); } else { setError(requestError.message); @@ -808,10 +859,13 @@ export default function AskUserPortal({ api }) { setSession(null); setAssignment(null); setExhausted(false); + setInvalidTask(""); }; if (!session) return ; if (exhausted) return ; + if (invalidTask) + return loadAssignment()} />; async function confirm() { if (!selected.length) return; setBusy(true); @@ -890,7 +944,7 @@ export default function AskUserPortal({ api }) { onConfirm={confirm} busy={busy} /> - ) : ["待审核", "已通过"].includes(assignment.status) ? ( + ) : ["待审核", "已通过", "已完成"].includes(assignment.status) ? ( loadAssignment()} diff --git a/frontend/src/CreateAskTasksPage.jsx b/frontend/src/CreateAskTasksPage.jsx index 9bf8102..0bb6191 100644 --- a/frontend/src/CreateAskTasksPage.jsx +++ b/frontend/src/CreateAskTasksPage.jsx @@ -196,9 +196,9 @@ export default function CreateAskTasksPage({ api }) { 提问任务链接 - {window.location.origin}/ask/{task.task_id} + {window.location.origin}/ask/{task.public_token} - 当前链接为任务预留入口,后续接入提问用户页面。 + 该随机链接用于用户领取任务,请勿通过任务ID拼接访问。
{task.selected_questions.length === 0 ? (
暂无问题
) : ( - task.selected_questions.map((question, index) => ( -
- {index + 1} - - Q:{question.question} - A:{question.answer} - - - {task.user_id} - {maskPhone(task.phone)} - - - - {task.status_label} - - -
- )) +
+ {task.selected_questions.map((question, index) => ( + + + {index + 1} + + + Q:{question.question} + A:{question.answer} + + + {task.user_id} + {maskPhone(task.phone)} + + + + {task.status_label} + + + + ))} + + {refundScreenshots.length ? ( + + ) : ( + "未上传" + )} + +
)}
@@ -223,10 +270,19 @@ function TaskDetail({ task, close, onConfirmRefund }) {

请确认已按用户上传的收款码完成付款。确认后,该任务将归入“已完成”,此操作不可撤销。

+
{loading ? ( @@ -590,9 +654,10 @@ export default function TaskBoardPage({ api }) { {task.product_name}{maskPhone(task.phone)} {task.task_type_label} {task.qa_count} - {task.pending_review_count} / {task.completed_count} + {task.completed_count} {task.status_label} {task.creator_name}{formatTime(task.created_at)} + {task.refund_screenshot_count ? "已上传" : "--"} {task.can_cancel && ( @@ -614,9 +679,10 @@ export default function TaskBoardPage({ api }) { {child.product_name}{maskPhone(child.phone)} 提问任务 {child.qa_count} - {child.pending_review_count} / {child.completed_count} + {child.completed_count} {child.status_label} {child.creator_name}{formatTime(child.created_at)} + {child.refund_proof_uploaded ? "已上传" : child.refund_endpoint ? "待上传" : "--"}
))} diff --git a/frontend/src/admin.css b/frontend/src/admin.css index ce6285b..9044a6a 100644 --- a/frontend/src/admin.css +++ b/frontend/src/admin.css @@ -11,6 +11,12 @@ .ask-exhausted-card p{max-width:330px;margin:0;color:#71859e;font-size:13px;line-height:1.8} .ask-exhausted-card small{margin:22px 0 14px;color:#91a1b5} .ask-exhausted-card button{width:100%;padding:12px;border-radius:10px;background:#1764e8;color:#fff;font-weight:700;box-shadow:0 7px 16px rgba(23,100,232,.2)} +.ask-invalid-card{display:flex;align-items:center;flex-direction:column;width:min(420px,calc(100% - 32px));margin:90px auto 24px;padding:34px 24px;border:1px solid #b8d0f3;border-radius:20px;background:linear-gradient(180deg,#f8fbff,#fff);text-align:center;box-shadow:0 12px 32px rgba(26,70,135,.1)} +.ask-invalid-icon{display:grid;place-items:center;width:72px;height:72px;margin-bottom:14px;border:1px solid #8bb4ef;border-radius:22px;background:#eaf3ff;color:#2864e8} +.ask-invalid-card>em{padding:5px 12px;border-radius:999px;background:#eaf2ff;color:#2864e8;font-size:11px;font-style:normal;font-weight:700} +.ask-invalid-card h1{margin:18px 0 8px;color:#102744;font-size:24px} +.ask-invalid-card p{max-width:330px;margin:0;color:#71859e;font-size:13px;line-height:1.8} +.ask-invalid-card button{width:100%;margin-top:22px;padding:12px;border-radius:10px;background:#1764e8;color:#fff;font-weight:700;box-shadow:0 7px 16px rgba(23,100,232,.2)} .ask-claim-summary{margin:12px 16px;padding:14px 16px;border:1px solid #b9d4ff;border-radius:14px;background:linear-gradient(135deg,#f5f9ff,#eef5ff);color:#194b91}.ask-claim-summary>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px}.ask-claim-summary strong{font-size:13px}.ask-claim-summary span,.ask-claim-summary small{color:#7188a5;font-size:11px}.ask-claim-summary>b{display:block;margin-top:8px;color:#1764e8;font-size:18px}.ask-claim-progress{height:7px;margin:8px 0;border-radius:9px;background:#dce8f8;overflow:hidden}.ask-claim-progress i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#2166ef,#5b46ee)} .ask-question-list>button.used{border-color:#d7e1ec;background:#f3f6fa;color:#7d8fa5;cursor:not-allowed;box-shadow:none}.ask-question-list>button.used i{border-color:#d0dbe7;background:#e6edf5;color:#8ca0b6}.ask-question-list>button em{margin-left:auto;padding:4px 7px;border-radius:8px;background:#e6edf5;color:#7288a2;font-size:10px;font-style:normal;white-space:nowrap} .ask-status-page{padding:14px 16px 80px}.ask-status-page .ask-history-tabs{margin-top:0}.ask-status-page .ask-history-item{box-shadow:0 2px 7px rgba(32,62,102,.05)} @@ -1867,7 +1873,7 @@ .board-table-head, .board-table-row { display: grid; - grid-template-columns: 34px 1.05fr 1.05fr 1.9fr 0.75fr 0.55fr 0.75fr 0.75fr 1fr minmax(150px, 1.2fr); + grid-template-columns: 34px 1.05fr 1.05fr 1.9fr 0.75fr 0.55fr 0.75fr 0.75fr 1fr 0.75fr minmax(150px, 1.2fr); align-items: center; gap: 10px; padding: 13px 14px; @@ -1936,6 +1942,11 @@ .board-child-row .board-actions button { color: #2864e8; } +.board-refund-proof { + color: #12965b; + font-size: 11px; + white-space: nowrap; +} .board-select-cell { display: grid; place-items: center; @@ -2180,7 +2191,7 @@ .task-detail-table .head, .task-detail-table .row { display: grid; - grid-template-columns: 45px 2fr 1fr 0.7fr; + grid-template-columns: 45px 2fr 1fr 0.7fr 0.9fr; gap: 10px; align-items: center; padding: 11px; @@ -2190,6 +2201,29 @@ color: #647b98; font-size: 11px; } +.task-detail-table-body { + display: grid; + grid-template-columns: 45px 2fr 1fr 0.7fr 0.9fr; +} +.task-detail-cell { + display: grid; + min-width: 0; + align-content: center; + gap: 5px; + padding: 11px; + color: #536b88; + font-size: 11px; +} +.task-detail-cell.row-border { + border-top: 1px solid #edf1f6; +} +.task-detail-cell b { + color: #203c5e; +} +.task-detail-cell small { + color: #8094ac; + line-height: 1.5; +} .task-detail-table .row { border-top: 1px solid #edf1f6; color: #536b88; @@ -2211,6 +2245,73 @@ text-align: center; color: #8a9cb1; } +.task-detail-refund-proof button { + display: grid; + gap: 4px; + padding: 0; + background: transparent; + color: #12965b; + font-size: 10px; +} +.task-detail-refund-proof img { + width: 48px; + height: 48px; + object-fit: cover; + border-radius: 6px; +} +.task-detail-refund-proof-merged { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + padding: 11px; + border-left: 1px solid #edf1f6; + color: #8094ac; + font-size: 10px; + text-align: center; +} +.ask-refund-proof-card { + margin-top: 12px; + padding: 14px; + border: 1px solid #bfe8d1; + border-radius: 12px; + background: #f4fff8; +} +.ask-refund-proof-card > header { + display: flex; + justify-content: space-between; + margin-bottom: 10px; + color: #168451; + font-size: 12px; +} +.ask-refund-proof-card > div, +.refund-proof-user > div { + display: flex; + gap: 9px; + flex-wrap: wrap; +} +.ask-refund-proof-card figure, +.refund-proof-user figure { + margin: 0; + overflow: hidden; + border: 1px solid #d3e8da; + border-radius: 8px; + background: #fff; +} +.ask-refund-proof-card img, +.refund-proof-user img { + display: block; + width: 96px; + height: 96px; + object-fit: cover; +} +.ask-refund-proof-card figcaption, +.refund-proof-user figcaption { + padding: 5px; + color: #5d816b; + font-size: 10px; + text-align: center; +} .task-detail-evidence { display: grid; grid-template-columns: 1.5fr 1fr; @@ -2325,6 +2426,25 @@ font-size: 12px; line-height: 1.75; } +.refund-proof-upload { + display: grid; + gap: 7px; + margin-top: 16px; + padding: 12px; + border: 1px dashed #b8cbe2; + border-radius: 10px; + color: #294767; + text-align: left; +} +.refund-proof-upload input { + width: 100%; +} +.refund-proof-upload small { + overflow: hidden; + color: #8195ad; + text-overflow: ellipsis; + white-space: nowrap; +} .refund-confirm-modal > div { display: grid; grid-template-columns: 1fr 1.35fr; @@ -2389,7 +2509,8 @@ overflow-x: auto; } .task-detail-table .head, - .task-detail-table .row { + .task-detail-table .row, + .task-detail-table-body { min-width: 620px; } .task-detail-evidence { @@ -4655,3 +4776,117 @@ body img { bottom: 14px; } } + +.ask-invalid-product-link { + display: flex; + align-items: center; + padding: 8px; + border-radius: 8px; + background: #fff0f3; + color: #c93652; + font-size: 10px; + white-space: nowrap; +} + +/* Public mobile portals: prevent fixed action bars, long filenames and modal + content from creating a horizontal overflow on narrow DingTalk webviews. */ +@media (max-width: 700px) { + .ask-user-app, + .answer-v2-app, + .ask-user-app * , + .answer-v2-app * { + min-width: 0; + } + + .ask-user-app input, + .ask-user-app textarea, + .ask-user-app select, + .answer-v2-app input, + .answer-v2-app textarea, + .answer-v2-app select { + max-width: 100%; + } + + .ask-user-sticky { + gap: 8px; + width: 100%; + padding-inline: 10px; + } + + .ask-user-sticky span { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .ask-user-sticky button { + flex: 0 1 180px; + width: min(180px, 46vw); + min-width: 0; + white-space: nowrap; + } + + .ask-product-card > footer, + .ask-payment-file { + min-width: 0; + flex-wrap: wrap; + } + + .ask-product-card > footer code, + .ask-payment-file span { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .ask-product-card > footer code { + flex: 1 1 100%; + } + + .ask-payment-file span { + flex: 1 1 100%; + } + + .ask-submit-backdrop { + align-items: end; + padding: 8px; + } + + .ask-submit-modal { + width: 100%; + max-height: calc(100dvh - 16px); + overflow: auto; + border-radius: 16px; + } + + .ask-submit-modal > footer { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + padding: 14px; + } + + .ask-submit-modal > footer button { + min-width: 0; + white-space: nowrap; + } + + .ask-invalid-card, + .ask-exhausted-card { + width: calc(100% - 24px); + margin: 56px auto 24px; + padding: 28px 18px; + } + + .ask-invalid-card h1, + .ask-exhausted-card h1 { + font-size: 21px; + } +} +.ask-audit-card>span.refund-proof-badge{width:auto;min-width:60px;height:38px;padding:0 14px;border-radius:12px;font-size:13px;font-weight:700} +.ask-audit-card>div.ask-audit-refund-proof{display:flex;align-items:center;justify-content:center;gap:8px;width:auto;min-height:60px;margin:0;padding:0;border:0;background:transparent;text-align:center} +.ask-audit-refund-proof figure{margin:0;text-align:center} +.ask-audit-refund-proof img{display:block;width:78px;height:78px;object-fit:cover;border:1px solid #67ca98;border-radius:12px;background:#fff} +.ask-audit-refund-proof figcaption{margin-top:4px;color:#168451;font-size:10px} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 4f6a519..d0aed8b 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -90,7 +90,10 @@ function LoginPage({ onLogin }) { const autoLoginStarted = useRef(false); const query = new URLSearchParams(window.location.search); const getDingTalkBridge = () => window.dd || window.DingTalkPC || window.dingtalk; - const inDingtalkWorkbench = /DingTalk|AliApp\(DT/i.test(navigator.userAgent || "") || query.get("from") === "dingtalk" || Boolean(getDingTalkBridge()); + // The DingTalk SDK is loaded on every page, including a normal browser. + // Do not treat the mere presence of `window.dd` as proof that we are inside + // the DingTalk workbench, otherwise local/browser users lose password login. + const inDingtalkWorkbench = /DingTalk|AliApp\(DT/i.test(navigator.userAgent || "") || query.get("from") === "dingtalk"; function loadDingTalkSdk() { if (getDingTalkBridge()) return Promise.resolve(getDingTalkBridge()); return new Promise((resolve, reject) => {