commit dbcce795364337845e35f91aabe20e169d7cecae Author: Claude Date: Fri Aug 7 09:43:18 2026 +0800 chore: 基线提交(重构前快照) 重构前保存现有 apps/ 结构基线,供后续各阶段回滚。 Co-Authored-By: Claude diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61c4c5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +.DS_Store +.buildpath +.hgignore.swp +.project +.orig +.swp +.idea/ +.settings/ +.vscode/ +vender/ +log/ +composer.lock +gitpush.sh +pkg/ +bin/ +cbuild +*/.DS_Store +.vscode + +.svn +evui/ +document/ +# ---- DjangoAdmin refactor additions ---- +.env +.env.* +!.env.example +__pycache__/ +*.py[cod] +.pytest_cache/ +*.egg-info/ +build/ +dist/ +venv/ +.venv/ +logs/ +uploads/ diff --git a/11.txt b/11.txt new file mode 100644 index 0000000..535e3c2 --- /dev/null +++ b/11.txt @@ -0,0 +1,99 @@ +已按企业级标准完成全量审查(约 1.4 万行 Python,覆盖 25 个业务模块 + 核心框架)。以下按严重程度分类汇总。 + + --- + 一、严重 BUG(功能已损坏 / 需立即修复) + + 1. 上传接口全部 500 —— 缺 request 参数 + + apps/views/upload.py:15/23/31 三个端点签名是 async def upload_image(file: UploadFile = File(...)),没有 Request 参数。而 permission_required 装饰器(apps/middleware/permission_required.py:11-19)靠 _extract_request() + 从参数里找 Request,找不到就传 None 进 _check(None) → get_user_id(None) → None.state 抛 AttributeError → 所有用户(含 admin)上传图片/文件都报 500。 + + ▎ 修复:三个函数都补上 request: Request 参数。 + + 2. Excel 导入功能必崩 + + apps/services/level.py:190:result = await upload.upload_file(...) 返回的是 JSONResponse(R.ok/R.failed),不是 dict,result.get('code') 抛 AttributeError → 500。职级导入完全不可用(全库仅此一处)。 + + ▎ 修复:改为 result.status_code == 200 后 json.loads(result.body),或让 upload.upload_file 返回 dict。 + + 3. 权限字符串系统性不匹配 → 非 admin 用户全部被拒(RBAC 失效) + + - 视图层要求:sys:{module}:page(分页)、sys:{module}:batch_delete(批量删除)——见 apps/views/level.py:19/96 及全部模块。 + - 权限节点生成器 apps/services/menu.py:244/274 生成的是 sys:{module}:list 和 sys:{module}:dall。 + - 种子数据 document/djangoadmin.fastapi.elevue.sql 存的也是 sys:level:list / sys:level:dall。 + - 只有 dept 和 menu 视图用 list(与种子一致),其余 20+ 模块全不一致。 + + 结果:非 admin 用户即使在菜单里勾选了"查询",拿到的是 sys:level:list,但 /level/page 端点校验 sys:level:page → 永远"权限不足"。批量删除节点(dall)更是完全无效。 + + ▎ 修复:统一三处(视图、set_permission、种子 SQL)的权限字符串,建议全部收敛为 page / batch_delete。 + + --- + 二、高危(安全隐患 / 影响稳定性) + + 4. 操作日志中间件同步 HTTP 调用阻塞事件循环 + + apps/middleware/operation_log.py:74-91 在 finally 里直接(未走 to_thread)调用 record_operation_log → utils/ip.py:59 的 get_ip_location 用同步 urllib.request.urlopen(..., timeout=3) 访问 + ipapi.co。每次写操作都可能把整个事件循环卡住最长 3 秒,并发下是稳定性隐患。(login_log 在登录服务里已用 asyncio.to_thread 包装,唯独 operation_log 中间件漏了。) + + ▎ 修复:把 record_operation_log 包进 asyncio.to_thread;或 IP 定位改为异步/本地库。 + + 5. 分页参数非数字 → 500(全模块) + + 所有 services 都是 int(request.query_params.get("pageNo", 1)),传 ?pageNo=abc 即抛 ValueError。apps/__init__.py 全局异常只处理了 Authorization/Business/RequestValidation 三类,未捕获 ValueError → 裸 500。22 + 个模块的列表页全中招。 + + 6. 省市区字段 NOT NULL 冲突 → IntegrityError + + apps/forms/member.py:16-18、apps/forms/user.py:20-22 的 province_code/city_code/district_code 是 Optional(None),但模型 apps/models/member.py:66-70、user.py:74-78 是 nullable=False。当 city 数组不足 3 个元素时,字段保持 None → + 插入/更新抛 IntegrityError → 500。add_user/add_member/update_* 均受影响。 + + 7. 上传大小校验在整文件读入内存之后 + + apps/services/upload.py:204-208 先 await file.read() 全量读入内存,再比对 UPLOAD_MAX_SIZE_MB。攻击者传超大文件即可耗尽内存(DoS)。应先用 Content-Length 头预检,或流式分段读取。 + + 8. 存储型 XSS 风险 + + - .svg 在允许列表中,_sanitize_svg 的 apps/services/upload.py:101-128 正则可被绕过(HTML 实体、foreignObject、命名空间等),且无 X-Content-Type-Options: nosniff 保障(若 Nginx 直接服务 uploads/ 目录则风险实锤)。 + - apps/services/notice.py:91/122 富文本 content 只转义 title,正文 HTML 原样存储并原样返回(get_notice_detail 第 76 行),无服务端清洗。公告若展示给前台用户即为存储型 XSS。 + + 9. 密钥/配置管理 + + - .env 未列入 .gitignore(当前项目是 SVN 且 .env 在根目录),内含 DB 密码 root、Redis 密码 123456、JWT 密钥。若入库即泄露。 + - .env 中 FASTAPI_UPLOAD_DIR=E:\DjangoAdmin\... 是写死的异机绝对路径(与当前目录 D:\Temp\... 不符);FASTAPI_IMAGE_URL='http://images.fastapi.elevue' 是假域名。FASTAPI_DEBUG=True、DB_DEBUG=True 默认开。 + - 本应用没有挂载静态目录(全库无 StaticFiles/mount),上传文件必须靠外部 Nginx 服务,否则返回的 fileUrl 不可访问——部署时需确认。 + + --- + 三、中低危 / 优化点 + + 事务与数据一致性 + - apps/services/job.py:216/239 在请求内直接 db.commit(),与 db_session_middleware 统一提交重复(冗余);且 pause_job_by_id 里 job.update_user = job.create_user(应为当前操作人 get_user_id(request))。 + - update_user_info(apps/services/user.py:312-329)bulk update 不触发 onupdate,update_time/update_user 不会被更新。 + + 性能 + - 权限 JOIN(menu.get_permissions_list_sync,apps/services/menu.py:332-345)对 user_role.user_id/role_id、role_menu.menu_id/role_id、menu.parent_id、user.dept_id 等列全无索引,非 admin 每次请求全表扫描。建议加索引。 + - perm_cache.invalidate_role_perms(utils/perm_cache.py:117-118)在异步上下文里直接同步 db.query,未走 to_thread。 + - 22 个模块的 get_*_page 分页/回退逻辑高度重复,可抽取公共分页组件。 + + 健壮性 + - get_access_token(utils/jwt.py:422)replace('Bearer ', '') 大小写敏感,客户端发 bearer 小写会解析失败。 + - captcha 防刷 redis.get 后 incr 存在 TOCTOU 竞态(utils/captcha.py:106-130),并发可绕过上限。 + - detail 路由 id 为裸字符串(apps/views/level.py:28),未做 int 校验,非法值直接进 SQL。 + - base_model.batch_delete_r(apps/models/base_model.py:176)只要有一个 id 不存在就整体报"记录不存在"(all-or-nothing),行为需与前端确认。 + - rich_text.save_file(utils/rich_text.py:58-69)对 URL 中的 .. 未规范化,存在目录穿越到上传目录外的可能(需源文件已存在,低危)。 + + 一致性/代码质量 + - JobLog 用 del_flag(apps/models/job_log.py:46),其余模型统一 is_delete;create_user/update_user 用 String(50) 而非 Integer。 + - forms/dept.py 校验 type 仅 1-2,而模型注释是 1-4。 + - config/sms.py 读的 SMS_URL/SMS_API_KEY... 与 .env 中实际变量(SMS_ALIYUN_*)不一致(当前无用,属预留模块)。 + - R.ok 允许 kwargs 覆盖 code,可能产生 {"ok": True, "code": 1} 的矛盾响应;R.failed 默认 code=1 与中间件"code==0 即成功"判断需保持口径一致。 + - 无测试套件、无 lint/format 配置(CLAUDE.md 已注明),建议至少补 pytest 覆盖鉴权中间件与 CRUD 主路径。 + + --- + 四、建议修复优先级 + + 1. 立即:#1 上传 500、#2 Excel 导入 500、#3 权限字符串统一(功能不可用 / RBAC 失效) + 2. 尽快:#4 事件循环阻塞、#5 分页 500、#6 NOT NULL 冲突、#7 上传内存 DoS + 3. 安全:#8 富文本/SVG XSS、#9 密钥与 DEBUG 配置 + 4. 后续:#11 索引、#12 公共分页组件、其余一致性项 + + 需要我直接开始修复吗?可以按优先级逐个处理,或先修最严重的 #1–#3(三个都是几行的改动,我可以一次性完成并自测)。 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..65c44c6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,114 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +DjangoAdmin FastAPI+EleVue is a backend admin framework built with FastAPI (Python), Vue2.x + ElementUI (frontend, separate repo), and MySQL. It provides RBAC-based user/role/menu management, dictionary/config management, and CMS-like modules (notices, ads, members, etc.). The UI language and code comments are in Chinese. + +## Running the Application + +```bash +# Install dependencies +pip install -r requirements.txt + +# Run dev server (default: 127.0.0.1:8031) +python main.py + +# Run via uvicorn directly +uvicorn main:app --host 127.0.0.1 --port 8031 + +# Docker +docker-compose up --build +``` + +Requires MySQL and Redis running. Configure connection details in `.env` (see `.env` for all variables). The database table prefix is `fastapi_` (set via `DB_PREFIX`). + +No test suite, linter, or formatter configuration exists in this project. + +## Architecture + +### Request Flow + +`main.py` -> `apps.create_app()` -> registers CORS, Redis, routers, middleware, exception handlers -> all routes go through `login_required` dependency (except `/login` and `/captcha`) -> `permission_required` decorator checks node-level RBAC per endpoint -> `check_demo` decorator blocks write operations in demo mode. + +### Layered Module Pattern + +Each business module (e.g., level, position, dept, role, user, menu, notice, dicts, config, etc.) follows the same 4-file pattern under `apps/`: + +- **`views/{module}.py`** - FastAPI router with endpoint definitions. Uses `@permission_required("sys:{module}:{action}")` and `@check_demo` decorators. Returns `R.ok()`/`R.failed()`. Endpoints that accept a body declare a Pydantic form parameter (e.g., `data: PositionForm`) — FastAPI parses and validates the JSON automatically before the handler runs. +- **`services/{module}.py`** - Business logic: queries, CRUD operations, Excel import/export. Uses `db.query(Model)` directly with SQLAlchemy. Reads query params via `request.query_params`; body params arrive as already-validated Pydantic models, so services access fields via `data.field` or `data.model_dump()` (no `await request.json()` in service bodies). +- **`forms/{module}.py`** - Pydantic `BaseModel` validation classes. Fields use `Field(...)` for constraints (`min_length`, `max_length`, `ge`, `le`, `gt`). Custom validators use `@field_validator('field')` raising `ValueError(msg)`. Uniqueness checks query `db.query(Model).filter(...).first()` inside validators. +- **`models/{module}.py`** - SQLAlchemy ORM model inheriting `base_model` (provides id, create_user, create_time, update_user, update_time, is_delete) and `base_db` (provides save(), save_all(), delete()). Table name uses `DB_PREFIX` prefix. + +### Request Validation + +Pydantic v2 is used for all request body validation. The pattern for an endpoint: + +```python +@router.post('/add') +@permission_required("sys:level:add") +@check_demo +async def add(request: Request, data: LevelForm): + return await level.LevelAdd(request, data) +``` + +Validation errors are caught by a global `RequestValidationError` handler registered in `apps/__init__.py` and converted to the standard response shape `{"code": -1, "data": null, "msg": "field: message/..."}` with HTTP 200. Services receive the validated Pydantic instance and use `data.model_dump()` (full dict), `data.model_dump(exclude={'id'})` (for updates), or direct attribute access (`data.id`, `data.status`). + +### Key Directories + +- **`config/`** - Configuration split into sub-modules: `app.py` (app settings, CORS, upload limits), `database.py` (DB connection, pool config, `SQLALCHEMY_MYSQL_URL`), `redis.py`, `auth.py` (JWT), `captcha.py`, `email.py`, `sms.py`. All loaded from `.env` via `python-dotenv`. `config/__init__.py` loads `.env` first, then re-exports everything. +- **`extensions/`** - Framework extensions: SQLAlchemy engine/session/contextvars (`extensions/sqlalchemy.py`), custom exception class (`extensions/exception.py`), `get_db_session()` accessor. +- **`routers/v1.py`** - Central route registration. All module routers are included here with prefix paths. +- **`apps/middleware/`** - `login_required` (JWT auth dependency, ignores `/login` and `/captcha`), `permission_required` (decorator checking user's permission nodes; admin userId=1 bypasses all checks), `check_demo` (decorator that blocks write operations when `FASTAPI_DEMO=True`), `db_session` (Starlette middleware setting request-scoped DB session via contextvars). +- **`utils/`** - `response` (JSON response helpers: `R.ok()`, `R.failed()`), `jwt` (JWT create/parse/refresh/blacklist with HS256), `password` (bcrypt-based password hashing with salt), `security` (`get_user_id(request)`, `get_username(request)` — reads from `request.state` set by `login_required`), `request` (`parse_batch_ids`, `like_escape`, `like_contains` for safe LIKE queries), `captcha` (verification code generation), `file` (file operations), `redis_ctx` (Redis connection pool lifecycle and request-scoped context via `get_redis()`), `ip`, `formatter`, `rich_text`. +- **`apps/constants/`** - `constants.py` (enum dicts for gender, dept types, menu types, etc.), `message.py` (default page size, messages, `SERIALIZE_EXCLUDE_FIELDS`). + +### Database Access + +Database sessions are request-scoped via `contextvars` + Starlette middleware (`apps/middleware/db_session.py`). Services call `db = get_db_session()` to get the current request's session. Models use `self.save()` / `self.delete()` from `base_db`. The middleware auto-closes the session after each request (with rollback). Soft deletes via `is_delete` flag (0=active, 1=deleted). + +### Redis Access + +Redis uses async client via `utils/redis_ctx.py`. Connection pool created in `redis_lifespan` (app lifespan), injected per-request via `redis_middleware` into contextvars. Business code calls `get_redis()` to get the current request's async Redis client. + +### Authentication & Authorization + +- JWT tokens via `utils/jwt.py` (HS256, configurable expiry via JWT_EXPIRE_MINUTES, secret via JWT_SALT env var). Token sent as `Authorization: Bearer ` header. Supports token refresh and blacklist (logout) via Redis. +- `login_required` is a FastAPI `Depends` applied globally to the v1 router. On success, it sets `request.state.user_id`, `request.state.username`, `request.state.realname`. +- `permission_required` is a per-endpoint decorator checking permission strings like `"sys:level:list"`. User ID 1 (admin) bypasses all permission checks. +- `check_demo` is a per-endpoint decorator that returns `R.failed("演示环境,暂无操作权限")` when `FASTAPI_DEMO=True`. Apply it after `@permission_required` on all write endpoints (add, update, delete, status, import, export). + +### API Response Format + +All endpoints return `JSONResponse` via `R.ok()` or `R.failed()`: +```json +{"code": 0, "data": ..., "msg": "操作成功", "ok": true} // success +{"code": -1, "data": null, "msg": "错误信息", "ok": false} // failure +``` +`R.ok()` supports `**kwargs` for extra fields (e.g., `R.ok(data=result, count=total)` adds a `count` field). + +### Pagination Pattern + +List endpoints read `pageNo` and `pageSize` from `request.query_params`, defaulting to page 1 and `PAGE_SIZE` (10). Services use SQLAlchemy `.limit(limit).offset((page - 1) * limit).all()` and return `R.ok(data=result, count=count)`. + +### Delete Pattern + +Single delete: `DELETE /delete/{id}` where id can be comma-separated (e.g., `"1,2,3"`). Batch delete: `DELETE /batchDelete` with JSON body `[1, 2, 3]`, parsed via `parse_batch_ids(request)`. Both use soft delete (set `is_delete = 1`). + +## Adding a New Module + +1. Create `apps/models/{name}.py` - model class inheriting `base_model` and `base_db`, set `__tablename__ = DB_PREFIX + "{name}"` +2. Create `apps/forms/{name}.py` - Pydantic `BaseModel` validation classes with `Field(...)` constraints and `@field_validator` for custom checks +3. Create `apps/services/{name}.py` - CRUD service functions following the `get_level_page`/`get_level_detail`/`add_level`/`update_level`/`delete_level`/`update_level_status` pattern +4. Create `apps/views/{name}.py` - APIRouter with endpoints, apply `@permission_required` and `@check_demo` on write endpoints +5. Register the router in `routers/v1.py` - `v1.include_router({name}.router, prefix="/{prefix}", tags=["标签"])` +6. Import the view in `routers/v1.py` + +## Key Dependencies + +- FastAPI 0.140.0, Uvicorn 0.51.0 +- SQLAlchemy 2.0.51 (declarative style with `declarative_base()`, not 2.0 `mapped_column` style) +- PyMySQL 1.2.0, Redis 8.0.1 (async), PyJWT 2.13.0 +- Pydantic 2.13.4 (for request validation via BaseModel + Field + field_validator), openpyxl (for Excel import/export) +- python-dotenv for `.env` loading, bcrypt for password hashing, Pillow for captcha generation diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..319c772 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# 基础镜像 +FROM python:3.9 +# 镜像作者 +MAINTAINER 管理员 +# 设置 python 环境变量 +ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE 1 + +# 设置时区 +ENV TZ Asia/Shanghai + +# 更新pip +RUN pip install -U pip +# 设置国内镜像源 +RUN pip config set global.index-url http://mirrors.aliyun.com/pypi/simple +RUN pip config set install.trusted-host mirrors.aliyun.com +# 升级pip版本 +RUN python -m pip install --upgrade pip +# 安装工具 +RUN pip install setuptools + +# 创建容器工作目录 +RUN mkdir -p /data/apps +# 设置容器内工作目录 +WORKDIR /data/apps + +# 将当前主机目录全部文件复制至容器工作目录 +ADD . /data/apps +# 也可以单个拷贝 +# COPY ./requirements.txt . + +# 安装依赖 +RUN pip install -r requirements.txt +RUN pip install uwsgi \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..5e212b0 --- /dev/null +++ b/README.en.md @@ -0,0 +1,36 @@ +# DjangoAdmin_Fastapi_EleVue + +#### Description +一款 Python 语言基于Fastapi、Vue2.x、ElementUI、MySQL等框架精心打造的一款模块化、高性能、企业级的敏捷开发框架,本着简化开发、提升开发效率的初衷触发,框架自研了一套个性化的组件,实现了可插拔的组件式开发方式:单图上传、多图上传、下拉选择、开关按钮、单选按钮、多选按钮、图片裁剪等等一系列个性化、轻量级的组件,是一款真正意义上实现组件化开发的敏捷开发框架。 + +#### Software Architecture +Software architecture description + +#### Installation + +1. xxxx +2. xxxx +3. xxxx + +#### Instructions + +1. xxxx +2. xxxx +3. xxxx + +#### Contribution + +1. Fork the repository +2. Create Feat_xxx branch +3. Commit your code +4. Create Pull Request + + +#### Gitee Feature + +1. You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md +2. Gitee blog [blog.gitee.com](https://blog.gitee.com) +3. Explore open source project [https://gitee.com/explore](https://gitee.com/explore) +4. The most valuable open source project [GVP](https://gitee.com/gvp) +5. The manual of Gitee [https://gitee.com/help](https://gitee.com/help) +6. The most popular members [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/) diff --git a/README.md b/README.md new file mode 100644 index 0000000..a42f6c5 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +## 📚 项目介绍 + +一款 Python 语言基于FastAPI、Vue2.x、ElementUI、MySQL等框架精心打造的一款模块化、高性能、企业级的敏捷开发框架,本着简化开发、提升开发效率的初衷触发,框架自研了一套个性化的组件,实现了可插拔的组件式开发方式:单图上传、多图上传、下拉选择、开关按钮、单选按钮、多选按钮、图片裁剪等等一系列个性化、轻量级的组件,是一款真正意义上实现组件化开发的敏捷开发框架。 + +## 🍻 项目特点 + ++ 模块化、松耦合 ++ 模块丰富、开箱即用 ++ 简洁易用、快速接入 ++ 文档详尽、易于维护 ++ 自顶向下、体系化设计 ++ 统一框架、统一组件、降低选择成本 ++ 开发规范、设计模式、代码分层模型 ++ 强大便捷的开发工具链 ++ 完善的本地中文化支持 ++ 设计为团队及企业使用 + +## 🍪 内置模块 + ++ 用户管理:用于维护管理系统的用户,常规信息的维护与账号设置。 ++ 角色管理:角色菜单管理与权限分配、设置角色所拥有的菜单权限。 ++ 菜单管理:配置系统菜单,操作权限,按钮权限标识等。 ++ 职级管理:主要管理用户的职级。 ++ 岗位管理:主要管理用户担任职务。 ++ 部门管理:配置系统组织机构(公司、部门、小组),树结构展现支持数据权限。 ++ 字典管理:对系统中常用的较为固定的数据进行统一维护。 ++ 配置管理:对系统的常规配置信息进行维护,网站配置管理功能进行统一维护。 ++ 通知公告:系统通知公告信息发布维护。 + +## 👷 软件信息 + +* 软件名称:DjangoAdmin敏捷开发框架FastAPI+EleVue版本 +* 软件作者:@一米阳光 团队荣誉出品 +* 软件出处:一米阳光团队 +* 软件协议:Apache-2.0 +* 官网网址:[https://www.djangoadmin.cn](https://www.djangoadmin.cn) +* 文档网址:[http://docs.fastapi.elevue.djangoadmin.cn](http://docs.fastapi.elevue.djangoadmin.cn) + +## 🎨 系统演示 + ++ 演示地址:http://manage.fastapi.elevue.djangoadmin.cn + +账号 | 密码| 操作权限 +---|---|--- +admin | 123456| 演示环境无法进行修改删除操作 + +## 📌 版本说明 + +版本名称 | 版本说明 | 版本地址 +---|---|--- +Django+Layui混编版 | 采用Django、Layui等框架研发 | https://gitee.com/djangoadmin/DjangoAdmin_Django_Layui +Flask+Layui混编版 | 采用Flask、Layui等框架研发 | https://gitee.com/djangoadmin/DjangoAdmin_Flask_Layui +FastAPI+Layui混编版 | 采用FastAPI、Layui等框架研发 | https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_Layui +Tornado+Layui混编版 | 采用Tornado、Layui等框架研发 | https://gitee.com/djangoadmin/DjangoAdmin_Tornado_Layui +Django+EleVue前后端分离版 | 采用Django、Vue2.x、ElementUI等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Django_EleVue +Flask+EleVue前后端分离版 | 采用Flask、Vue2.x、ElementUI等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Flask_EleVue +FastAPI+EleVue前后端分离版 | 采用FastAPI、Vue2.x、ElementUI等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_EleVue +Tornado+EleVue前后端分离版 | 采用Tornado、Vue2.x、ElementUI等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Tornado_EleVue +Django+AntdVue前后端分离版 | 采用Django、Vue3.x、AntDesign等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Django_AntdVue +Flask+AntdVue前后端分离版 | 采用Flask、Vue3.x、AntDesign等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Flask_AntdVue +FastAPI+AntdVue前后端分离版 | 采用FastAPI、Vue3.x、AntDesign等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_AntdVue +Tornado+AntdVue前后端分离版 | 采用Tornado、Vue、AntDesign等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Tornado_AntdVue + +## 🔧 模块展示 + +![效果图](./uploads/demo/1.png) + +![效果图](./uploads/demo/2.png) + +![效果图](./uploads/demo/3.png) + +![效果图](./uploads/demo/4.png) + +![效果图](./uploads/demo/5.png) + +![效果图](./uploads/demo/6.png) + +![效果图](./uploads/demo/7.png) + +![效果图](./uploads/demo/8.png) + +![效果图](./uploads/demo/9.png) + +![效果图](./uploads/demo/10.png) + +![效果图](./uploads/demo/11.png) + +![效果图](./uploads/demo/12.png) + +![效果图](./uploads/demo/13.png) + +![效果图](./uploads/demo/14.png) + +![效果图](./uploads/demo/15.png) + +![效果图](./uploads/demo/16.png) + +![效果图](./uploads/demo/17.png) + +![效果图](./uploads/demo/18.png) + +![效果图](./uploads/demo/19.png) + +![效果图](./uploads/demo/20.png) + +![效果图](./uploads/demo/21.png) + +![效果图](./uploads/demo/22.png) + +## ✨ 特别鸣谢 + +感谢[FastAPI](https://fastapi.tiangolo.com/)、[Vue](https://cn.vuejs.org/)、[ElementUI](https://element.eleme.cn/#/)等优秀开源项目。 + +## 📚 版权信息 + +商业版使用需授权,未授权禁止恶意传播和用于商业用途,任何个人、单位、组织授权所获取的框架源码禁止分享、转售、恶意传播、开源(包括框架源码及二次开发后的产品)、禁止简单更改包装声称是自己的产品,否则将追究相关人的法律责任。 + +本软件框架禁止任何单位和个人用于任何违法、侵害他人合法利益等恶意的行为,禁止用于任何违反我国法律法规的一切平台研发,任何单位和个人使用本软件框架用于产品研发而产生的任何意外、疏忽、合约毁坏、诽谤、版权或知识产权侵犯及其造成的损失 ( +包括但不限于直接、间接、附带或衍生的损失等),本团队不承担任何法律责任。本软件框架只能用于公司和个人内部的法律所允许的合法合规的软件产品研发,详细声明内容请阅读《框架免责声明》附件; + +本项目包含的第三方源码和二进制文件之版权信息另行标注。 + +版权所有Copyright © 2021~2026 [djangoadmin.cn](https://www.djangoadmin.cn) All rights reserved。 + +更多细节参阅 [LICENSE](LICENSE) \ No newline at end of file diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..71d044b --- /dev/null +++ b/apps/__init__.py @@ -0,0 +1,157 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 一米阳光团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +import time +import traceback + +from fastapi import FastAPI +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.logger import logger +from starlette import status +from starlette.requests import Request +from starlette.responses import JSONResponse + +from extensions.exception import AuthorizationException, BusinessException +from extensions import register_extends +from routers import register_router +from apps.middleware import register_middleware, register_cros +from apps.middleware.db_session import db_session_middleware +from apps.middleware.operation_log import operation_log_middleware +from utils.redis_ctx import redis_lifespan, redis_middleware +from config.app import FASTAPI_DEBUG + + +# 创建应用 +def create_app() -> FastAPI: + # # 实例化应用并鉴权 + app = FastAPI(title="FastAPI", + description="FastAPI", + version="v1", + lifespan=redis_lifespan, + ) + + # 注册跨域中间件 + register_cros(app) + # 注册Redis上下文注入中间件(先注册→内层:先注入Redis再开DB会话) + app.middleware("http")(redis_middleware) + # 注册数据库会话中间件(后注册→外层:DB会话包裹业务逻辑,finally保证关闭) + app.middleware("http")(db_session_middleware) + # 注册操作日志中间件(在DB会话内层,记录写操作日志) + app.middleware("http")(operation_log_middleware) + # 注册路由 + register_router(app) + # 初始化扩展 + register_extends(app) + # 登录检测 + register_middleware(app) + # 注册全局异常处理 + register_exception(app) + + # 请求计时(最后注册→最外层,统计完整耗时) + @app.middleware("http") + async def add_process_time_header(request: Request, call_next): + # 开始时间 + start_time = time.time() + response = await call_next(request) + # 过程时间 + process_time = time.time() - start_time + # X- 作为前缀代表专有自定义请求头 + response.headers["X-Process-Time"] = str(round(process_time, 5)) + if FASTAPI_DEBUG: + logger.info(f"访问记录:{request.method} url:{request.url} 耗时:{str(round(process_time, 5))}") + # 放行 + return response + + # 返回应用 + return app + + +# 注册全局异常处理 +def register_exception(app: FastAPI): + """ + 全局异常捕获 + :param app: + :return: + """ + + # 登录认证异常处理执行句柄 + @app.exception_handler(AuthorizationException) + async def unicorn_exception_handler(request: Request, exc: AuthorizationException): + return JSONResponse( + status_code=200, + content={ + "code": exc.code, + "data": None, + "msg": exc.msg, + "ok": False, + }, + ) + + # 业务异常处理执行句柄 + @app.exception_handler(BusinessException) + async def business_exception_handler(request: Request, exc: BusinessException): + return JSONResponse( + status_code=200, + content={ + "code": exc.code, + "data": None, + "msg": exc.msg, + "ok": False, + }, + ) + + # Pydantic 请求参数校验异常处理 + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, exc: RequestValidationError): + errors = [] + for err in exc.errors(): + # 取字段路径最后一段作为字段名 + field = err.get("loc", [""])[-1] + msg = err.get("msg", "") + if field and field != "body": + errors.append(f"{field}: {msg}") + else: + errors.append(msg) + return JSONResponse( + status_code=200, + content={"code": 1, "data": None, "msg": "/".join(errors)}, + ) diff --git a/apps/constants/__init__.py b/apps/constants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/constants/constants.py b/apps/constants/constants.py new file mode 100644 index 0000000..e78eeef --- /dev/null +++ b/apps/constants/constants.py @@ -0,0 +1,205 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 一米阳光团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +# 性别 +GENDER_LIST = { + 1: "男", + 2: "女", + 3: "保密", +} +# 部门类型 +DEPT_TYPE_LIST = { + 1: "公司", + 2: "子公司", + 3: "部门", + 4: "小组", +} + +# 站点类型 +ITEM_TYPE_LIST = { + 1: "普通站点", + 2: "其他站点" +} + +# 友链类型:1友情链接 2合作伙伴 +LINK_TYPE_LIST = { + 1: "友情链接", + 2: "合作伙伴" +} + +# 友链投放平台:1PC站 2WAP站 3微信小程序 4APP应用 +LINK_PLATFORM_LIST = { + 1: "PC站", + 2: "WAP站", + 3: "微信小程序", + 4: "APP应用" +} + +# 友链形式:1文字链接 2图片链接 +LINK_FORM_LIST = { + 1: "文字链接", + 2: "图片链接", +} + +# 广告投放平台:1PC站 2WAP站 3微信小程序 4APP应用 +AD_SORT_PLATFORM_LIST = { + 1: "PC站", + 2: "WAP站", + 3: "微信小程序", + 4: "APP应用" +} + +# 广告类型:1图片 2文字 3视频 4推荐 +AD_TYPE_LIST = { + 1: "图片", + 2: "文字", + 3: "视频", + 4: "推荐" +} + +# 通知来源:1官方平台 2开源中国 3CSDN官方 4新浪微博 +NOTICE_SOURCE_LIST = { + 1: "官方平台", + 2: "开源中国", + 3: "CSDN官方", + 4: "新浪微博", +} + +# 会员注册来源:1-网站注册 2-客户端注册 3-小程序注册 4-手机站注册 5-后台添加 +MEMBER_SOURCE_LIST = { + 1: "网站注册", + 2: "客户端注册", + 3: "小程序注册", + 4: "手机站注册", + 5: "后台添加" +} + +# 配置项类型 +CONFIG_DATA_TYPE_LIST = { + "text": "单行文本", + "textarea": "多行文本", + "ueditor": "富文本编辑器", + "date": "日期", + "datetime": "时间", + "number": "数字", + "select": "下拉框", + "radio": "单选框", + "checkbox": "复选框", + "image": "单张图片", + "images": "多张图片", + "password": "密码", + "icon": "字体图标", + "file": "单个文件", + "files": "多个文件", + "hidden": "隐藏", + "readonly": "只读文本", +} + +# 菜单类型:0-菜单 1-节点 +MENU_TYPE_LIST = { + 0: '菜单', + 1: '节点', +} + +# 打开方式:1-内部打开 2-外部打开 +MENU_TARGET_LIST = { + 1: '内部打开', + 2: '外部打开', +} + +# 城市级别 +CITY_LEVEL_LIST = { + 1: "省份", + 2: "城市", + 3: "县区", + 4: "街道" +} + +# 日志类型:1-登录,2-退出 +LOG_TYPE_LIST = { + 1: "登录", + 2: "退出", +} + +# 登录来源:0-后台,1-前台 +LOGIN_SOURCE_LIST = { + 0: "后台", + 1: "前台", +} + +# 登录状态:0-正常,1-异常 +LOGIN_STATUS_LIST = { + 0: "正常", + 1: "异常", +} + +# 操作日志来源:0-后台,1-前台 +OPERATION_SOURCE_LIST = { + 0: "后台", + 1: "前台", +} + +# 操作日志状态:0-正常,1-异常 +OPERATION_STATUS_LIST = { + 0: "正常", + 1: "异常", +} + +# 任务状态:0-未发布,1-运行中,2-暂停,3-删除 +JOB_STATUS_LIST = { + 0: "未发布", + 1: "运行中", + 2: "暂停", + 3: "删除", +} + +# 执行策略:1-立即执行,2-执行一次,3-放弃执行 +JOB_EXECUTE_POLICY_LIST = { + 1: "立即执行", + 2: "执行一次", + 3: "放弃执行", +} + +# 任务日志执行状态:0-正常 1-失败 +JOB_LOG_STATUS_LIST = { + 0: "正常", + 1: "失败", +} diff --git a/apps/constants/message.py b/apps/constants/message.py new file mode 100644 index 0000000..df85b4f --- /dev/null +++ b/apps/constants/message.py @@ -0,0 +1,85 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 珊瑚云团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 珊瑚云团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +# ======================= 分页配置 ========================== +# 该部分配置用于控制列表页面的数据分页显示 +# 通过调整每页记录数可以优化页面加载速度和用户体验 +# ========================================================== + +# 默认每页显示记录数 +# 类型:int +# 默认值:10 +# 说明:列表页面默认每页显示的数据条数,可根据实际需求调整 +PAGE_SIZE = 10 + +# ======================= 用户配置 ========================== +# 该部分配置用于管理用户账户相关的默认设置 +# 包含新用户创建和密码重置时的默认值 +# ========================================================== + +# 默认密码 +# 类型:str +# 默认值:"123456" +# 说明:新创建用户或重置密码时的默认密码,建议首次登录后强制修改 +DEFAULT_PASSWORD = "123456" + +# ======================= 消息提示配置 ========================== +# 该部分配置定义了所有操作响应的统一消息文本 +# 用于保持系统前后端消息提示的一致性 +# ========================================================== + +# 操作成功提示信息 +# 类型:str +# 默认值:"操作成功" +# 说明:统一的操作成功返回消息,所有成功操作均使用此消息 +MESSAGE_OK = "操作成功" + +# 操作失败提示信息 +# 类型:str +# 默认值:"操作失败" +# 说明:统一的操作失败返回消息,所有失败操作均使用此消息 +MESSAGE_FAIL = "操作失败" + +# ======================= 序列化安全配置 ========================== +# 模型序列化时统一排除的敏感字段,防止密码、盐值等泄露给前端 +# ================================================================== + +SERIALIZE_EXCLUDE_FIELDS = frozenset({'is_delete', 'password', 'salt'}) diff --git a/apps/forms/__init__.py b/apps/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/forms/ad.py b/apps/forms/ad.py new file mode 100644 index 0000000..ef30b5a --- /dev/null +++ b/apps/forms/ad.py @@ -0,0 +1,44 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +# 广告表单验证 +class AdForm(BaseModel): + # 广告ID + id: Optional[int] = Field(None, description="广告ID") + # 广告标题 + title: str = Field(..., min_length=1, max_length=255, description="广告标题") + # 广告位ID + sort_id: int = Field(..., ge=0, description="广告位ID") + # 广告类型:1-图片 2-文字 3-视频 4-推荐 + type: int = Field(..., ge=1, le=4, description="广告类型") + # 广告封面 + cover: Optional[str] = Field(None, max_length=255, description="广告封面") + # 广告URL + url: str = Field(..., min_length=1, max_length=255, description="广告URL") + # 广告宽度 + width: int = Field(..., ge=0, description="广告宽度") + # 广告高度 + height: int = Field(..., ge=0, description="广告高度") + # 开始时间 + start_time: datetime = Field(..., description="开始时间") + # 结束时间 + end_time: datetime = Field(..., description="结束时间") + # 广告状态 + status: int = Field(..., ge=1, le=2, description="广告状态") + # 广告排序 + sort: int = Field(..., ge=0, le=99999, description="广告排序") + # 广告备注 + note: Optional[str] = Field(None, max_length=255, description="广告备注") + # 广告内容 + content: Optional[str] = Field(None, description="广告内容") + + +# 设置状态表单 +class AdStatusForm(BaseModel): + # 广告ID + id: int = Field(..., gt=0, description="广告ID") + # 广告状态 + status: int = Field(..., ge=1, le=2, description="广告状态") diff --git a/apps/forms/ad_sort.py b/apps/forms/ad_sort.py new file mode 100644 index 0000000..dbbcd74 --- /dev/null +++ b/apps/forms/ad_sort.py @@ -0,0 +1,23 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +# 广告位表单验证 +class AdSortForm(BaseModel): + # 广告位ID + id: Optional[int] = Field(None, description="广告位ID") + # 广告位名称 + name: str = Field(..., min_length=1, max_length=255, description="广告位名称") + # 站点ID + item_id: Optional[int] = Field(None, description="站点ID") + # 栏目ID + cate_id: Optional[int] = Field(None, description="栏目ID") + # 广告位置 + loc_id: int = Field(..., ge=0, description="广告位置") + # 投放平台:1-PC站 2-WAP站 3-微信小程序 4-APP应用 + platform: int = Field(..., ge=1, le=4, description="投放平台") + # 广告位描述 + description: str = Field(..., min_length=1, max_length=255, description="广告位描述") + # 广告位排序 + sort: int = Field(..., ge=0, le=99999, description="广告位排序") diff --git a/apps/forms/city.py b/apps/forms/city.py new file mode 100644 index 0000000..5e03c85 --- /dev/null +++ b/apps/forms/city.py @@ -0,0 +1,18 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +# 城市表单验证 +class CityForm(BaseModel): + id: Optional[int] = Field(None, description="城市ID") + name: str = Field(..., min_length=1, max_length=150, description="城市名称") + short_name: str = Field(..., min_length=1, max_length=150, description="城市简称") + pinyin: str = Field(..., min_length=1, max_length=150, description="城市拼音") + city_code: str = Field(..., min_length=1, max_length=6, description="城市区号") + area_code: str = Field(..., min_length=1, max_length=20, description="行政编码") + zip_code: str = Field(..., min_length=1, max_length=6, description="城市邮编") + level: int = Field(..., ge=1, le=4, description="城市级别") + lng: str = Field(..., min_length=1, max_length=150, description="城市经度") + lat: str = Field(..., min_length=1, max_length=150, description="城市纬度") + pid: Optional[int] = Field(None, description="上级ID") diff --git a/apps/forms/config.py b/apps/forms/config.py new file mode 100644 index 0000000..02194af --- /dev/null +++ b/apps/forms/config.py @@ -0,0 +1,11 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +# 配置表单验证 +class ConfigForm(BaseModel): + id: Optional[int] = Field(None, description="配置ID") + name: str = Field(..., min_length=1, max_length=150, description="配置名称") + sort: int = Field(..., ge=0, le=99999, description="配置排序") + note: Optional[str] = Field(None, max_length=255, description="配置备注") + diff --git a/apps/forms/config_item.py b/apps/forms/config_item.py new file mode 100644 index 0000000..5853b45 --- /dev/null +++ b/apps/forms/config_item.py @@ -0,0 +1,22 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +# 配置项表单验证 +class ConfigItemForm(BaseModel): + id: Optional[int] = Field(None, description="配置项ID") + title: str = Field(..., min_length=1, max_length=150, description="配置项标题") + code: str = Field(..., min_length=1, max_length=150, description="配置项编码") + value: str = Field(..., min_length=1, max_length=1000, description="配置项值") + options: Optional[str] = Field(None, max_length=1000, description="配置选项") + config_id: int = Field(..., ge=0, description="配置ID") + type: str = Field(..., min_length=1, max_length=150, description="配置类型") + sort: int = Field(..., ge=0, le=99999, description="配置项排序") + note: Optional[str] = Field(None, max_length=255, description="配置项备注") + + +# 设置状态表单 +class ConfigItemStatusForm(BaseModel): + id: int = Field(..., gt=0, description="配置项ID") + status: int = Field(..., ge=1, le=2, description="配置项状态") diff --git a/apps/forms/dept.py b/apps/forms/dept.py new file mode 100644 index 0000000..3543a3a --- /dev/null +++ b/apps/forms/dept.py @@ -0,0 +1,14 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +# 部门表单验证 +class DeptForm(BaseModel): + id: Optional[int] = Field(None, description="部门ID") + name: str = Field(..., min_length=1, max_length=150, description="部门名称") + code: str = Field(..., min_length=1, max_length=150, description="部门编码") + type: int = Field(..., ge=1, le=2, description="部门类型") + pid: Optional[int] = Field(None, description="上级ID") + sort: int = Field(..., ge=0, le=99999, description="部门排序") + note: Optional[str] = Field(None, max_length=255, description="部门备注") diff --git a/apps/forms/dict.py b/apps/forms/dict.py new file mode 100644 index 0000000..9df4362 --- /dev/null +++ b/apps/forms/dict.py @@ -0,0 +1,12 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +# 字典表单验证 +class DictForm(BaseModel): + id: Optional[int] = Field(None, description="字典ID") + name: str = Field(..., min_length=1, max_length=150, description="字典名称") + code: str = Field(..., min_length=1, max_length=150, description="字典编码") + sort: int = Field(..., ge=0, le=99999, description="字典排序") + note: Optional[str] = Field(None, max_length=255, description="字典备注") + diff --git a/apps/forms/dict_item.py b/apps/forms/dict_item.py new file mode 100644 index 0000000..47d761e --- /dev/null +++ b/apps/forms/dict_item.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +# 字典项表单验证 +class DictItemForm(BaseModel): + id: Optional[int] = Field(None, description="字典项ID") + name: str = Field(..., min_length=1, max_length=150, description="字典项名称") + value: str = Field(..., min_length=1, max_length=150, description="字典项值") + dict_id: int = Field(..., ge=0, description="字典ID") + note: Optional[str] = Field(None, max_length=255, description="字典项备注") + sort: int = Field(..., ge=0, le=99999, description="字典项排序") + +# 设置状态表单 +class DictItemStatusForm(BaseModel): + id: int = Field(..., gt=0, description="字典项ID") + status: int = Field(..., ge=1, le=2, description="字典项状态") diff --git a/apps/forms/item.py b/apps/forms/item.py new file mode 100644 index 0000000..d15e2ab --- /dev/null +++ b/apps/forms/item.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +# 站点表单验证 +class ItemForm(BaseModel): + id: Optional[int] = Field(None, description="站点ID") + name: str = Field(..., min_length=1, max_length=150, description="站点名称") + type: int = Field(..., ge=1, le=2, description="站点类型") + url: str = Field(..., min_length=1, max_length=255, description="站点URL") + image: str = Field(..., min_length=1, max_length=255, description="站点图片") + status: int = Field(..., ge=1, le=2, description="站点状态") + sort: int = Field(..., ge=0, le=99999, description="站点排序") + note: Optional[str] = Field(None, max_length=255, description="站点备注") + diff --git a/apps/forms/item_cate.py b/apps/forms/item_cate.py new file mode 100644 index 0000000..f1efc98 --- /dev/null +++ b/apps/forms/item_cate.py @@ -0,0 +1,18 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +# 栏目表单验证 +class ItemCateForm(BaseModel): + id: Optional[int] = Field(None, description="栏目ID") + name: str = Field(..., min_length=1, max_length=150, description="栏目名称") + pid: Optional[int] = Field(None, description="上级ID") + item_id: int = Field(..., ge=0, description="站点ID") + pinyin: str = Field(..., min_length=1, max_length=150, description="拼音全称") + code: str = Field(..., min_length=1, max_length=150, description="拼音简称") + is_cover: int = Field(..., ge=1, le=2, description="是否有封面") + cover: Optional[str] = Field(None, max_length=255, description="栏目封面") + status: int = Field(..., ge=1, le=2, description="栏目状态") + sort: int = Field(..., ge=0, le=99999, description="栏目排序") + note: Optional[str] = Field(None, max_length=255, description="栏目备注") + diff --git a/apps/forms/job.py b/apps/forms/job.py new file mode 100644 index 0000000..562556e --- /dev/null +++ b/apps/forms/job.py @@ -0,0 +1,32 @@ +from typing import Optional + +from pydantic import BaseModel, Field, field_validator + + +# 定时任务表单验证 +class JobForm(BaseModel): + id: Optional[int] = Field(None, description="任务ID") + job_name: str = Field(..., min_length=1, max_length=255, description="任务名称") + job_alias: Optional[str] = Field(None, max_length=255, description="任务别名") + job_group: Optional[str] = Field(None, max_length=255, description="任务分组") + job_trigger: Optional[str] = Field(None, max_length=255, description="任务触发器") + status: int = Field(..., ge=0, le=3, description="任务状态") + cron_expression: Optional[str] = Field(None, max_length=255, description="Cron表达式") + execute_policy: int = Field(..., ge=1, le=3, description="执行策略") + is_sync: int = Field(..., ge=0, le=1, description="是否同步任务") + url: Optional[str] = Field(None, max_length=255, description="任务URL") + params: Optional[str] = Field(None, max_length=2000, description="任务参数") + note: Optional[str] = Field(None, max_length=255, description="任务备注") + + @field_validator('cron_expression') + @classmethod + def validate_cron(cls, v): + if v: + v = v.strip() + return v + + +# 任务状态表单 +class JobStatusForm(BaseModel): + id: int = Field(..., gt=0, description="任务ID") + status: int = Field(..., ge=0, le=3, description="任务状态") diff --git a/apps/forms/level.py b/apps/forms/level.py new file mode 100644 index 0000000..d150ce1 --- /dev/null +++ b/apps/forms/level.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +# 职级表单验证 +class LevelForm(BaseModel): + id: Optional[int] = Field(None, description="职级ID") + name: str = Field(..., min_length=1, max_length=150, description="职级名称") + status: int = Field(..., ge=1, le=2, description="职级状态") + sort: int = Field(..., ge=0, le=99999, description="职级排序") + +# 设置状态表单 +class LevelStatusForm(BaseModel): + id: int = Field(..., gt=0, description="职级ID") + status: int = Field(..., ge=1, le=2, description="职级状态") diff --git a/apps/forms/link.py b/apps/forms/link.py new file mode 100644 index 0000000..f98b7cf --- /dev/null +++ b/apps/forms/link.py @@ -0,0 +1,29 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from apps.models.link import Link + + +# 友链表单验证 +class LinkForm(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + id: Optional[int] = Field(None, description="友链ID") + name: str = Field(..., min_length=1, max_length=255, description="友链名称") + type: int = Field(..., ge=1, le=2, description="友链类型") + url: str = Field(..., min_length=1, max_length=255, description="友链URL") + item_id: Optional[int] = Field(None, description="站点ID") + cate_id: Optional[int] = Field(None, description="栏目ID") + platform: int = Field(..., ge=1, le=4, description="投放平台") + link_form: int = Field(..., ge=1, le=2, alias='form', description="友链形式") + image: str = Field(..., min_length=1, max_length=255, description="友链图片") + status: int = Field(..., ge=1, le=2, description="友链状态") + sort: int = Field(..., ge=0, le=99999, description="友链排序") + note: Optional[str] = Field(None, max_length=255, description="友链备注") + + +# 设置状态表单 +class LinkStatusForm(BaseModel): + id: int = Field(..., gt=0, description="友链ID") + status: int = Field(..., ge=1, le=2, description="友链状态") diff --git a/apps/forms/login.py b/apps/forms/login.py new file mode 100644 index 0000000..d453f6c --- /dev/null +++ b/apps/forms/login.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, Field + +from config import CAPTCHA_LENGTH + + +# 登录表单验证 +class LoginForm(BaseModel): + username: str = Field(..., min_length=1, max_length=20, description="登录账号") + password: str = Field(..., min_length=6, max_length=128, description="登录密码") + captcha: str = Field(..., min_length=CAPTCHA_LENGTH, max_length=CAPTCHA_LENGTH, description="验证码") + idKey: str = Field(..., min_length=1, description="KEY值") diff --git a/apps/forms/member.py b/apps/forms/member.py new file mode 100644 index 0000000..7a84cd8 --- /dev/null +++ b/apps/forms/member.py @@ -0,0 +1,31 @@ +from datetime import date +from typing import Any, Optional + +from pydantic import BaseModel, Field + +# 会员表单验证 +class MemberForm(BaseModel): + id: Optional[int] = Field(None, description="会员ID") + realname: str = Field(..., min_length=1, max_length=150, description="会员姓名") + nickname: str = Field(..., min_length=1, max_length=150, description="会员昵称") + gender: int = Field(..., ge=1, le=3, description="性别") + avatar: str = Field(..., min_length=1, max_length=255, description="头像") + birthday: date = Field(..., description="出生日期") + email: str = Field(..., min_length=1, max_length=30, description="邮箱") + city: Any = Field(..., description="行政区划") + province_code: Optional[str] = Field(None, max_length=30, description="省份编码") + city_code: Optional[str] = Field(None, max_length=30, description="城市编码") + district_code: Optional[str] = Field(None, max_length=30, description="县区编码") + address: str = Field(..., min_length=1, max_length=255, description="详细地址") + username: str = Field(..., min_length=1, max_length=30, description="登录账号") + password: Optional[str] = Field(None, max_length=255, description="登录密码") + member_level: int = Field(..., ge=0, description="会员等级") + intro: Optional[str] = Field(None, max_length=255, description="个人简介") + signature: Optional[str] = Field(None, max_length=255, description="个人签名") + source: int = Field(..., ge=1, le=5, description="注册来源") + status: int = Field(..., ge=1, le=2, description="会员状态") + +# 设置状态表单 +class MemberStatusForm(BaseModel): + id: int = Field(..., gt=0, description="会员ID") + status: int = Field(..., ge=1, le=2, description="会员状态") diff --git a/apps/forms/member_level.py b/apps/forms/member_level.py new file mode 100644 index 0000000..270fe42 --- /dev/null +++ b/apps/forms/member_level.py @@ -0,0 +1,10 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +# 会员等级表单验证 +class MemberLevelForm(BaseModel): + id: Optional[int] = Field(None, description="会员等级ID") + name: str = Field(..., min_length=1, max_length=255, description="会员等级名称") + sort: int = Field(..., ge=0, le=99999, description="会员等级排序") + diff --git a/apps/forms/menu.py b/apps/forms/menu.py new file mode 100644 index 0000000..33f20e5 --- /dev/null +++ b/apps/forms/menu.py @@ -0,0 +1,21 @@ +from typing import Any, List, Optional + +from pydantic import BaseModel, Field + + +# 菜单表单验证 +class MenuForm(BaseModel): + id: Optional[int] = Field(None, description="菜单ID") + title: str = Field(..., min_length=1, max_length=150, description="菜单标题") + icon: Optional[str] = Field(None, max_length=50, description="菜单图标") + path: Optional[str] = Field(None, max_length=255, description="路由地址") + component: Optional[str] = Field(None, max_length=255, description="组件路径") + parent_id: int = Field(..., ge=0, description="上级ID") + type: Optional[int] = Field(None, ge=0, le=1, description="菜单类型") + target: Optional[str] = Field(None, max_length=30, description="打开方式") + permission: Optional[str] = Field(None, max_length=150, description="权限节点") + hide: Optional[int] = Field(None, ge=0, le=1, description="是否可见") + status: int = Field(..., ge=1, le=2, description="菜单状态") + sort: int = Field(..., ge=0, le=99999, description="菜单排序") + note: Optional[str] = Field(None, max_length=255, description="菜单备注") + checked_list: Optional[List[int]] = Field(default=[], description="权限节点") diff --git a/apps/forms/notice.py b/apps/forms/notice.py new file mode 100644 index 0000000..ea54781 --- /dev/null +++ b/apps/forms/notice.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +# 通知表单验证 +class NoticeForm(BaseModel): + id: Optional[int] = Field(None, description="通知ID") + title: str = Field(..., min_length=1, max_length=255, description="通知标题") + source: int = Field(..., ge=1, le=4, description="通知来源") + url: str = Field(..., min_length=1, max_length=255, description="通知地址") + status: int = Field(..., ge=1, le=2, description="通知状态") + is_top: int = Field(..., ge=1, le=2, description="是否置顶") + click: int = Field(..., ge=0, description="点击率") + content: Optional[str] = Field(None, description="通知内容") diff --git a/apps/forms/position.py b/apps/forms/position.py new file mode 100644 index 0000000..f12917e --- /dev/null +++ b/apps/forms/position.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +# 岗位表单验证 +class PositionForm(BaseModel): + id: Optional[int] = Field(None, description="岗位ID") + name: str = Field(..., min_length=1, max_length=150, description="岗位名称") + status: int = Field(..., ge=1, le=2, description="岗位状态") + sort: int = Field(..., ge=0, le=99999, description="岗位排序") + +# 设置状态表单 +class PositionStatusForm(BaseModel): + id: int = Field(..., gt=0, description="岗位ID") + status: int = Field(..., ge=1, le=2, description="岗位状态") diff --git a/apps/forms/role.py b/apps/forms/role.py new file mode 100644 index 0000000..83db03c --- /dev/null +++ b/apps/forms/role.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +# 角色表单验证 +class RoleForm(BaseModel): + id: Optional[int] = Field(None, description="角色ID") + name: str = Field(..., min_length=1, max_length=150, description="角色名称") + code: str = Field(..., min_length=1, max_length=30, description="角色编码") + status: int = Field(..., ge=1, le=2, description="角色状态") + sort: int = Field(..., ge=0, le=99999, description="角色排序") + note: Optional[str] = Field(None, max_length=255, description="角色备注") + +# 设置状态表单 +class RoleStatusForm(BaseModel): + id: int = Field(..., gt=0, description="角色ID") + status: int = Field(..., ge=1, le=2, description="角色状态") diff --git a/apps/forms/role_menu.py b/apps/forms/role_menu.py new file mode 100644 index 0000000..ab70bf7 --- /dev/null +++ b/apps/forms/role_menu.py @@ -0,0 +1,9 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + + +# 角色菜单表单 +class RoleMenuForm(BaseModel): + role_id: int = Field(..., gt=0, description="角色ID") + menu_id: Optional[List[int]] = Field(default=[], description="菜单ID列表") diff --git a/apps/forms/user.py b/apps/forms/user.py new file mode 100644 index 0000000..662cac0 --- /dev/null +++ b/apps/forms/user.py @@ -0,0 +1,61 @@ +from datetime import date +from typing import Any, Optional + +from pydantic import BaseModel, Field, model_validator + +# 用户表单验证 +class UserForm(BaseModel): + id: Optional[int] = Field(None, description="用户ID") + realname: str = Field(..., min_length=1, max_length=150, description="用户姓名") + nickname: str = Field(..., min_length=1, max_length=150, description="用户昵称") + gender: int = Field(..., ge=1, le=3, description="性别") + avatar: str = Field(..., min_length=1, max_length=255, description="头像") + mobile: str = Field(..., min_length=1, max_length=30, description="手机号") + email: str = Field(..., min_length=1, max_length=30, description="邮箱") + birthday: date + dept_id: int = Field(..., ge=0, description="部门ID") + level_id: int = Field(..., ge=0, description="职级ID") + position_id: int = Field(..., ge=0, description="岗位ID") + city: Any = Field(..., description="行政区划") + province_code: Optional[str] = Field(None, max_length=30, description="省份编码") + city_code: Optional[str] = Field(None, max_length=30, description="城市编码") + district_code: Optional[str] = Field(None, max_length=30, description="县区编码") + address: str = Field(..., min_length=1, max_length=255, description="详细地址") + username: str = Field(..., min_length=1, max_length=30, description="登录账号") + password: Optional[str] = Field(None, min_length=6, max_length=128, description="登录密码") + intro: Optional[str] = Field(None, max_length=255, description="个人简介") + status: int = Field(..., ge=1, le=2, description="用户状态") + note: Optional[str] = Field(None, max_length=255, description="用户备注") + sort: int = Field(..., ge=0, le=99999, description="用户排序") + roles: Optional[Any] = Field(default=[], description="用户角色") + +# 设置状态表单 +class UserStatusForm(BaseModel): + id: int = Field(..., gt=0, description="用户ID") + status: int = Field(..., ge=1, le=2, description="用户状态") + +# 修改个人信息表单验证 +class UserInfoForm(BaseModel): + realname: str = Field(..., min_length=1, max_length=150, description="用户姓名") + nickname: str = Field(..., min_length=1, max_length=150, description="用户昵称") + gender: int = Field(..., ge=1, le=3, description="性别") + mobile: str = Field(..., min_length=1, max_length=30, description="手机号") + email: str = Field(..., min_length=1, max_length=30, description="邮箱") + address: str = Field(..., min_length=1, max_length=255, description="详细地址") + intro: Optional[str] = Field(None, max_length=255, description="个人简介") + +# 修改密码表单验证 +class UpdatePwdForm(BaseModel): + oldPassword: str = Field(..., min_length=6, max_length=128, description="原始密码") + newPassword: str = Field(..., min_length=6, max_length=128, description="新密码") + rePassword: str = Field(..., min_length=6, max_length=128, description="确认密码") + + @model_validator(mode='after') + def check_passwords_match(self): + if self.newPassword != self.rePassword: + raise ValueError('rePassword: 两次输入的密码不一致') + return self + +# 重置密码表单 +class ResetPwdForm(BaseModel): + id: int = Field(..., gt=0, description="用户ID") diff --git a/apps/middleware/.gitignore b/apps/middleware/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/apps/middleware/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/apps/models/.gitignore b/apps/models/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/apps/models/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/apps/services/__init__.py b/apps/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/services/ad.py b/apps/services/ad.py new file mode 100644 index 0000000..282e045 --- /dev/null +++ b/apps/services/ad.py @@ -0,0 +1,184 @@ +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + + +from apps.constants.constants import AD_TYPE_LIST +from apps.constants.message import PAGE_SIZE +from apps.forms.ad import AdForm, AdStatusForm +from apps.models.ad import Ad +from apps.models.ad_sort import AdSort +from config import FASTAPI_IMAGE_URL +from extensions import get_db_session +from utils import R +from utils.rich_text import get_file_url, save_file, save_content +from utils.request import like_contains +from utils.security import get_user_id + + +# 查询广告分页数据 + +def get_ad_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(Ad).filter(Ad.is_delete == 0) + # 广告标题 + title = request.query_params.get('title') + if title: + query = query.filter(Ad.title.like(like_contains(title), escape='\\')) + # 广告类型:1-图片 2-文字 3-视频 4-推荐 + type = request.query_params.get('type') + if type: + query = query.filter(Ad.type == type) + # 广告状态 + status = request.query_params.get('status') + if status: + query = query.filter(Ad.status == status) + # 排序 + query = query.order_by(Ad.sort.desc()) + # 记录总数 + count = query.count() + # 分页查询 + ad_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not ad_list: + page = 1 + count = query.count() + ad_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + # 遍历数据源 + if len(ad_list) > 0: + for item in ad_list: + # 对象转字典 + data = item.to_dict() + # 广告类型 + data['type_name'] = AD_TYPE_LIST.get(item.type) + # 广告封面 + data['cover'] = get_file_url(item.cover) if item.cover else None + # 查询广告位信息 + ad_sort = db.query(AdSort).filter(and_(AdSort.id == item.sort_id, AdSort.is_delete == 0)).first() + # 广告位描述 + data['sort_desc'] = ad_sort.name + "=>" + str(ad_sort.loc_id) if ad_sort else None + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据广告ID查询详情 + +def get_ad_detail(ad_id): + db = get_db_session() + # 根据广告ID查询信息 + ad = db.query(Ad).filter(and_(Ad.id == ad_id, Ad.is_delete == 0)).first() + # 查询结果为空判断 + if not ad: + return None + # 对象转字典 + data = ad.to_dict() + # 广告类型描述 + data['type_name'] = AD_TYPE_LIST.get(ad.type) + # 广告封面 + data['cover'] = get_file_url(ad.cover) if ad.cover else "" + # # 富文本内容 + # content = ad.content.replace("[IMG_URL]", FASTAPI_IMAGE_URL) + # data['content'] = content + # 返回结果 + return data +# 添加广告 + +def add_ad(request, data: AdForm): + db = get_db_session() + # 广告封面 + cover = data.cover + if data.type == 1 and cover: + cover = save_file(cover, "ad") + else: + cover = None + + # # 广告标题 + # title = data.title + # # 广告内容 + # content = data.content + # # 处理富文本内容 + # content = save_content(content, title, "ad") + + # 表单数据赋值给对象 + ad = Ad(**data.model_dump(exclude={'id'})) + ad.cover = cover + ad.create_user = get_user_id(request) + # 插入数据 + ad.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新广告 + +def update_ad(request, data: AdForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + # 根据ID查询记录 + ad = db.query(Ad).filter(and_(Ad.id == data.id, Ad.is_delete == 0)).first() + # 查询结果判空 + if not ad: + return R.failed("记录不存在") + + # 广告封面 + cover = data.cover + if data.type == 1 and cover: + cover = save_file(cover, "ad") + else: + cover = None + + # # 广告标题 + # title = data.title + # # 广告内容 + # content = data.content + # # 处理富文本内容 + # content = save_content(content, title, "ad") + + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + updData['cover'] = cover + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + # 更新记录 + result = db.query(Ad).filter_by(id=data.id).update(updData) + # 提交数据 + # 返回结果 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除广告 + +def delete_ad(ad_id): + return Ad.batch_delete_r(ad_id) +# 设置状态 + +def update_ad_status(request, data: AdStatusForm): + db = get_db_session() + # 根据ID查询记录 + ad = db.query(Ad).filter(and_(Ad.id == data.id, Ad.is_delete == 0)).first() + # 查询结果判空 + if not ad: + return R.failed("记录不存在") + + # 更新记录 + result = db.query(Ad).filter_by(id=data.id).update({ + "status": data.status, + "update_user": get_user_id(request), + "update_time": datetime.now(tz=timezone.utc) + }) + # 提交数据 + if not result: + return R.failed("设置失败") + # 返回结果 + return R.ok(msg="设置成功") diff --git a/apps/services/ad_sort.py b/apps/services/ad_sort.py new file mode 100644 index 0000000..7261a37 --- /dev/null +++ b/apps/services/ad_sort.py @@ -0,0 +1,141 @@ +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + + +from apps.constants.constants import AD_SORT_PLATFORM_LIST +from apps.constants.message import PAGE_SIZE +from apps.forms.ad_sort import AdSortForm +from apps.models.ad_sort import AdSort +from apps.models.item import Item +from apps.models.item_cate import ItemCate +from extensions import get_db_session +from utils import R +from utils.security import get_user_id +from utils.request import like_contains + + +# 获取广告位分页数据 + +def get_ad_sort_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(AdSort).filter(AdSort.is_delete == 0) + # 名称 + name = request.query_params.get('name') + if name: + query = query.filter(AdSort.name.like(like_contains(name), escape='\\')) + # 投放平台:1PC站 2WAP站 3微信小程序 4APP应用 + platform = request.query_params.get('platform') + if platform: + query = query.filter(AdSort.platform == platform) + # 栏目状态筛选 + status = request.query_params.get('status') + if status: + query = query.filter(AdSort.status == status) + # 排序 + query = query.order_by(AdSort.sort.asc()) + # 记录总数 + count = query.count() + # 分页查询 + ad_sort_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not ad_sort_list: + page = 1 + count = query.count() + ad_sort_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + # 遍历数据源 + if len(ad_sort_list) > 0: + for v in ad_sort_list: + # 对象转字典 + data = v.to_dict() + # 投放平台 + data['platform_name'] = AD_SORT_PLATFORM_LIST.get(v.platform) + # 站点信息 + item_info = db.query(Item).filter(and_(Item.id == v.item_id, Item.is_delete == 0)).first() + data['item_name'] = item_info.name if item_info else None + # 栏目信息 + cate_info = db.query(ItemCate).filter(and_(ItemCate.id == v.cate_id, ItemCate.is_delete == 0)).first() + data['cate_name'] = cate_info.name if cate_info else None + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据ID查询广告位详情 + +def get_ad_sort_detail(adsort_id): + db = get_db_session() + # 根据ID查询广告位 + adsort = db.query(AdSort).filter(and_(AdSort.id == adsort_id, AdSort.is_delete == 0)).first() + # 查询结果为空判断 + if not adsort: + return None + # 对象转字典 + data = adsort.to_dict() + # 返回结果 + return data +# 添加广告位 + +def add_ad_sort(request, data: AdSortForm): + db = get_db_session() + # 表单数据赋值给对象 + adsort = AdSort(**data.model_dump(exclude={'id'})) + adsort.create_user = get_user_id(request) + # 插入数据 + adsort.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新广告位 + +def update_ad_sort(request, data: AdSortForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + + # 根据ID查询记录 + adsort = db.query(AdSort).filter(and_(AdSort.id == data.id, AdSort.is_delete == 0)).first() + # 查询结果判空 + if not adsort: + return R.failed("记录不存在") + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + result = db.query(AdSort).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除广告位 + +def delete_ad_sort(adsort_id): + return AdSort.batch_delete_r(adsort_id) +# 查询广告位列表 + +def get_ad_sort_list(): + db = get_db_session() + # 查询全部广告位列表 + ad_sort_list = db.query(AdSort).filter(AdSort.is_delete == 0).all() + # 实例化广告位对象列表 + sortList = [] + if ad_sort_list: + for v in ad_sort_list: + item = { + 'id': v.id, + 'description': v.description + "=>" + str(v.loc_id) + } + # 加入列表 + sortList.append(item) + # 返回结果 + return sortList diff --git a/apps/services/city.py b/apps/services/city.py new file mode 100644 index 0000000..f539b9c --- /dev/null +++ b/apps/services/city.py @@ -0,0 +1,159 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 一米阳光团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + + +from apps.constants.constants import CITY_LEVEL_LIST +from apps.forms.city import CityForm +from apps.models.city import City +from extensions import get_db_session +from utils import R +from utils.security import get_user_id +from utils.request import like_contains + + +# 查询城市数据 + +def get_city_page(request): + db = get_db_session() + # 实例化查询对象 + query = db.query(City).filter(City.is_delete == 0) + # 上级ID + pid = request.query_params.get('pid', 0) + query = query.filter(City.pid == pid) + # 城市名称 + name = request.query_params.get('name') + if name: + # 城市名称模糊查询 + query = query.filter(City.name.like(like_contains(name), escape='\\')) + # 查询数据 + city_list = query.order_by(City.id.asc()).all() + # 实例化数组对象 + result = [] + # 遍历数据源 + if city_list: + for item in city_list: + # 对象转字典 + data = item.to_dict() + # 城市级别 + data['level_name'] = CITY_LEVEL_LIST.get(item.level) + # 是否有子级 + data['haveChild'] = True if item.level < 3 else False, + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result) +# 根据城市ID获取详情 + +def get_city_detail(city_id): + db = get_db_session() + # 根据ID查询城市 + city = db.query(City).filter(and_(City.id == city_id, City.is_delete == 0)).first() + # 查询结果判空 + if not city: + return None + # 对象转字典 + data = city.to_dict() + # 返回结果 + return data +# 添加城市 + +def add_city(request, data: CityForm): + db = get_db_session() + # 表单数据赋值给对象 + city = City(**data.model_dump(exclude={'id'})) + city.create_user = get_user_id(request) + # 插入数据 + city.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新城市 + +def update_city(request, data: CityForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + + # 根据ID查询记录 + city = db.query(City).filter(and_(City.id == data.id, City.is_delete == 0)).first() + # 查询结果判空 + if not city: + return R.failed("记录不存在") + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + result = db.query(City).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除城市 + +def delete_city(city_id): + return City.batch_delete_r(city_id) +# 根据城市ID获取子级城市 + +def get_child_list(city_code): + db = get_db_session() + # 查询子级城市 + childList = db.query(City).filter(and_(City.parent_code == city_code, City.is_delete == 0)).all() + # 实例化子级城市对象 + city_list = [] + # 遍历数据源 + if childList: + for v in childList: + item = { + 'area_code': v.area_code, + 'name': v.name + } + # 加入列表 + city_list.append(item) + # 返回结果 + return city_list diff --git a/apps/services/config.py b/apps/services/config.py new file mode 100644 index 0000000..e76a7f4 --- /dev/null +++ b/apps/services/config.py @@ -0,0 +1,229 @@ +import asyncio +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + + +from apps.constants.message import PAGE_SIZE +from apps.forms.config import ConfigForm +from apps.models.config import Config +from apps.models.config_item import ConfigItem +from config import FASTAPI_IMAGE_URL +from extensions import get_db_session +from utils import R +from utils.rich_text import get_file_url, save_file +from utils.request import like_contains +from utils.security import get_user_id + + +# 配置表单验证 + +def get_config_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(Config).filter(Config.is_delete == 0) + # 配置名称 + name = request.query_params.get('name') + if name: + query = query.filter(Config.name.like(like_contains(name), escape='\\')) + # 排序 + query = query.order_by(Config.sort.asc()) + # 记录总数 + count = query.count() + # 分页查询 + config_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not config_list: + page = 1 + count = query.count() + config_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + # 遍历数据源 + if len(config_list) > 0: + for item in config_list: + # 对象转字典 + data = item.to_dict() + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据ID获取配置详情 + +def get_config_detail(config_id): + db = get_db_session() + # 根据ID查询配置 + config = db.query(Config).filter(and_(Config.id == config_id, Config.is_delete == 0)).first() + # 查询结果判空 + if not config: + return None + # 对象转字典 + data = config.to_dict() + # 返回结果 + return data +# 添加配置 + +def add_config(request, data: ConfigForm): + db = get_db_session() + # 唯一性校验 + if db.query(Config).filter(and_(Config.name == data.name, Config.is_delete == 0)).first(): + return R.failed("配置名称不能重复") + # 表单数据赋值给对象 + config = Config(**data.model_dump(exclude={'id'})) + config.create_user = get_user_id(request) + # 插入数据 + config.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新配置 + +def update_config(request, data: ConfigForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + # 唯一性校验(排除自身) + if db.query(Config).filter(and_(Config.name == data.name, Config.id != data.id, Config.is_delete == 0)).first(): + return R.failed("配置名称不能重复") + + # 根据ID查询记录 + config = db.query(Config).filter(and_(Config.id == data.id, Config.is_delete == 0)).first() + # 查询结果判空 + if not config: + return R.failed("记录不存在") + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + result = db.query(Config).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除配置 + +def delete_config(config_id): + return Config.batch_delete_r(config_id) + + +# 解析配置项选项字符串为字典 +def _parse_options(options_str): + """将 'k1=v1,k2=v2' 格式的选项字符串解析为字典""" + if not options_str: + return {} + result = {} + for part in options_str.split(','): + parts = part.split('=', 1) + if len(parts) == 2: + result[parts[0]] = parts[1] + return result + + +# 获取网站配置信息 +def get_config_info(request): + db = get_db_session() + # 一次性查询所有配置组 + configList = db.query(Config).filter(Config.is_delete == 0).order_by(Config.sort.asc()).all() + if not configList: + return [] + + # 一次性查询所有活跃配置项,按config_id分组 + config_ids = [c.id for c in configList] + all_items = db.query(ConfigItem).filter( + and_(ConfigItem.is_delete == 0, ConfigItem.status == 1, ConfigItem.config_id.in_(config_ids)) + ).order_by(ConfigItem.sort.asc()).all() + + items_map = {} + for item in all_items: + items_map.setdefault(item.config_id, []).append(item) + + config_list = [] + for val in configList: + config = { + 'config_id': val.id, + 'config_name': val.name + } + + data_list = [] + for v in items_map.get(val.id, []): + data = { + "id": v.id, + "title": v.title, + "code": v.code, + "value": v.value, + "type": v.type, + } + + item_type = v.type + if item_type == "checkbox": + data['optionsList'] = _parse_options(v.options) + data["value"] = v.value.split(',') if v.value else [] + elif item_type in ("radio", "select"): + data['optionsList'] = _parse_options(v.options) + elif item_type == "image": + if v.value: + data["value"] = get_file_url(v.value) + elif item_type == "images": + if v.value: + data["value"] = [get_file_url(img) for img in v.value.split(',')] + + data_list.append(data) + + config['data_list'] = data_list + config_list.append(config) + + return config_list + + +# 保存网站配置信息 +async def save_config_info(request): + # 获取请求参数 + req_data = await request.json() + if not req_data or not isinstance(req_data, dict): + return R.failed("请求参数异常") + + def _save(): + db = get_db_session() + # 预加载所有活跃配置项,仅允许修改这些项 + valid_items = db.query(ConfigItem).filter( + and_(ConfigItem.is_delete == 0, ConfigItem.status == 1) + ).all() + item_map = {item.code: item for item in valid_items} + + # 数据源处理 + for key, val in req_data.items(): + # 仅允许修改已存在的活跃配置项,忽略非法key + config_item = item_map.get(key) + if not config_item: + continue + + # 图片数据 + item = [] + if isinstance(val, list): + for v in val: + if isinstance(v, str) and (v.startswith("http://") or v.startswith("https://")): + url = save_file(v, "config") + item.append(url) + else: + item.append(v) + val = ','.join(item) + else: + if isinstance(val, str) and (val.startswith("http://") or val.startswith("https://")): + val = save_file(val, "config") + + # 设置 + config_item.value = val + + # 同步DB操作与网络下载放入线程池,避免阻塞事件循环 + await asyncio.to_thread(_save) + + # 返回结果 + return R.ok() diff --git a/apps/services/config_item.py b/apps/services/config_item.py new file mode 100644 index 0000000..34098e8 --- /dev/null +++ b/apps/services/config_item.py @@ -0,0 +1,130 @@ +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + +from apps.constants.constants import CONFIG_DATA_TYPE_LIST +from apps.constants.message import PAGE_SIZE +from apps.forms.config_item import ConfigItemForm, ConfigItemStatusForm +from apps.models.config_item import ConfigItem +from extensions import get_db_session +from utils import R +from utils.security import get_user_id +from utils.request import like_contains + + +# 查询配置项分页数据 + +def get_config_item_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(ConfigItem).filter(ConfigItem.is_delete == 0) + # 配置ID + config_id = request.query_params.get('config_id', 0) + query = query.filter(ConfigItem.config_id == config_id) + # 配置项项名称模糊筛选 + title = request.query_params.get('title') + if title: + query = query.filter(ConfigItem.title.like(like_contains(title), escape='\\')) + # 排序 + query = query.order_by(ConfigItem.sort.asc()) + # 记录总数 + count = query.count() + # 分页查询 + config_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not config_list: + page = 1 + count = query.count() + config_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + # 遍历数据源 + if len(config_list) > 0: + for item in config_list: + # 对象转字典 + data = item.to_dict() + # 配置项类型 + data['type_name'] = CONFIG_DATA_TYPE_LIST.get(item.type) + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据ID查询配置项详情 + +def get_config_item_detail(config_id): + db = get_db_session() + # 根据ID查询配置项 + config_data = db.query(ConfigItem).filter(and_(ConfigItem.id == config_id, ConfigItem.is_delete == 0)).first() + # 查询结果判空 + if not config_data: + return None + # 对象转字典 + data = config_data.to_dict() + # 返回结果 + return data +# 添加配置项 + +def add_config_item(request, data: ConfigItemForm): + db = get_db_session() + # 表单数据赋值给对象 + config_data = ConfigItem(**data.model_dump(exclude={'id'})) + config_data.create_user = get_user_id(request) + # 插入数据 + config_data.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新配置项 + +def update_config_item(request, data: ConfigItemForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + + # 根据ID查询记录 + config_data = db.query(ConfigItem).filter(and_(ConfigItem.id == data.id, ConfigItem.is_delete == 0)).first() + # 查询结果判空 + if not config_data: + return R.failed("记录不存在") + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + result = db.query(ConfigItem).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除配置项 + +def delete_config_item(config_id): + return ConfigItem.batch_delete_r(config_id) +# 设置状态 + +def update_config_item_status(request, data: ConfigItemStatusForm): + db = get_db_session() + # 根据ID查询记录 + config_data = db.query(ConfigItem).filter(and_(ConfigItem.id == data.id, ConfigItem.is_delete == 0)).first() + # 查询结果判空 + if not config_data: + return R.failed("记录不存在") + + # 更新记录 + result = db.query(ConfigItem).filter_by(id=data.id).update({ + "status": data.status, + "update_user": get_user_id(request), + "update_time": datetime.now(tz=timezone.utc) + }) + # 提交数据 + if not result: + return R.failed("设置失败") + # 返回结果 + return R.ok(msg="设置成功") diff --git a/apps/services/dept.py b/apps/services/dept.py new file mode 100644 index 0000000..de44808 --- /dev/null +++ b/apps/services/dept.py @@ -0,0 +1,114 @@ +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + +from apps.forms.dept import DeptForm +from apps.models.dept import Dept +from extensions import get_db_session +from utils import R + +from utils.security import get_user_id +from utils.request import like_contains, parse_id_list + + +# 查询部门数据 + +def get_dept_page(request): + db = get_db_session() + # 实例化查询对象 + query = db.query(Dept).filter(Dept.is_delete == 0) + # 部门名称 + name = request.query_params.get('name') + if name: + # 部门名称模糊查询 + query = query.filter(Dept.name.like(like_contains(name), escape='\\')) + # 查询数据 + dept_list = query.order_by(Dept.sort.asc()).all() + # 实例化数组对象 + result = [] + # 遍历数据源 + if dept_list: + for item in dept_list: + # 对象转字典 + data = item.to_dict() + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result) +# 根据ID查询部门 + +def get_dept_detail(dept_id): + db = get_db_session() + # 根据ID查询部门 + dept = db.query(Dept).filter(and_(Dept.id == dept_id, Dept.is_delete == 0)).first() + # 查询结果判空 + if not dept: + return None + # 对象转字典 + data = dept.to_dict() + # 返回结果 + return data +# 添加部门 + +def add_dept(request, data: DeptForm): + db = get_db_session() + # 唯一性校验 + if db.query(Dept).filter(and_(Dept.name == data.name, Dept.is_delete == 0)).first(): + return R.failed("部门名称不能重复") + # 表单数据赋值给对象 + dept = Dept(**data.model_dump(exclude={'id'})) + dept.create_user = get_user_id(request) + # 插入数据 + dept.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新部门 + +def update_dept(request, data: DeptForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + # 唯一性校验(排除自身) + if db.query(Dept).filter(and_(Dept.name == data.name, Dept.id != data.id, Dept.is_delete == 0)).first(): + return R.failed("部门名称不能重复") + + # 根据ID查询记录 + dept = db.query(Dept).filter(and_(Dept.id == data.id, Dept.is_delete == 0)).first() + # 查询结果判空 + if not dept: + return R.failed("记录不存在") + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + result = db.query(Dept).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除部门 + +def delete_dept(dept_id): + return Dept.batch_delete_r(dept_id) +# 获取部门列表 + +def get_dept_list(): + db = get_db_session() + # 查询部门数据列表 + dept_rows = db.query(Dept).filter(Dept.is_delete == 0).order_by(Dept.sort.asc()).all() + # 实例化列表 + dept_list = [] + # 遍历数据源 + if dept_rows: + for v in dept_rows: + # 对象转字典 + item = v.to_dict() + # 加入数组 + dept_list.append(item) + # 返回结果 + return dept_list \ No newline at end of file diff --git a/apps/services/dict.py b/apps/services/dict.py new file mode 100644 index 0000000..b7454c8 --- /dev/null +++ b/apps/services/dict.py @@ -0,0 +1,109 @@ +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + +from apps.constants.message import PAGE_SIZE +from apps.forms.dict import DictForm +from apps.models.dict import Dict +from extensions import get_db_session +from utils import R +from utils.security import get_user_id +from utils.request import like_contains + + +# 查询字典分页数据 + +def get_dict_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(Dict).filter(Dict.is_delete == 0) + # 字典名称 + name = request.query_params.get('name') + if name: + query = query.filter(Dict.name.like(like_contains(name), escape='\\')) + # 排序 + query = query.order_by(Dict.sort.asc()) + # 记录总数 + count = query.count() + # 分页查询 + dict_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not dict_list: + page = 1 + count = query.count() + dict_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + # 遍历数据源 + if len(dict_list) > 0: + for item in dict_list: + # 对象转字典 + data = item.to_dict() + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据ID查询字典 + +def get_dict_detail(dict_id): + db = get_db_session() + # 根据ID查询字典 + dict = db.query(Dict).filter(and_(Dict.id == dict_id, Dict.is_delete == 0)).first() + # 查询结果判空 + if not dict: + return None + # 对象转字典 + data = dict.to_dict() + # 返回结果 + return data +# 添加字典 + +def add_dict(request, data: DictForm): + db = get_db_session() + # 唯一性校验 + if db.query(Dict).filter(and_(Dict.name == data.name, Dict.is_delete == 0)).first(): + return R.failed("字典名称不能重复") + # 表单数据赋值给对象 + dict = Dict(**data.model_dump(exclude={'id'})) + dict.create_user = get_user_id(request) + # 插入数据 + dict.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新字典 + +def update_dict(request, data: DictForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + # 唯一性校验(排除自身) + if db.query(Dict).filter(and_(Dict.name == data.name, Dict.id != data.id, Dict.is_delete == 0)).first(): + return R.failed("字典名称不能重复") + + # 根据ID查询记录 + dict = db.query(Dict).filter(and_(Dict.id == data.id, Dict.is_delete == 0)).first() + # 查询结果判空 + if not dict: + return R.failed("记录不存在") + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + result = db.query(Dict).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除字典 + +def delete_dict(dict_id): + return Dict.batch_delete_r(dict_id) diff --git a/apps/services/dict_item.py b/apps/services/dict_item.py new file mode 100644 index 0000000..c6f6dd4 --- /dev/null +++ b/apps/services/dict_item.py @@ -0,0 +1,133 @@ +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + +from apps.constants.message import PAGE_SIZE +from apps.forms.dict_item import DictItemForm, DictItemStatusForm +from apps.models.dict_item import DictItem +from extensions import get_db_session +from utils import R +from utils.security import get_user_id +from utils.request import like_contains + + +# 查询字典项分页数据 + +def get_dict_item_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(DictItem).filter(DictItem.is_delete == 0) + # 字典ID + dict_id = request.query_params.get('dict_id', 0) + query = query.filter(DictItem.dict_id == dict_id) + # 字典项名称 + name = request.query_params.get('name') + if name: + query = query.filter(DictItem.name.like(like_contains(name), escape='\\')) + # 排序 + query = query.order_by(DictItem.sort.asc()) + # 记录总数 + count = query.count() + # 分页查询 + dict_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not dict_list: + page = 1 + count = query.count() + dict_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + # 遍历数据源 + if len(dict_list) > 0: + for item in dict_list: + # 对象转字典 + data = item.to_dict() + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据ID查询字典项详情 + +def get_dict_item_detail(dict_id): + db = get_db_session() + # 根据ID查询字典项 + dict_data = db.query(DictItem).filter(and_(DictItem.id == dict_id, DictItem.is_delete == 0)).first() + # 查询结果判空 + if not dict_data: + return None + # 对象转字典 + data = dict_data.to_dict() + # 返回结果 + return data +# 添加字典项 + +def add_dict_item(request, data: DictItemForm): + db = get_db_session() + # 唯一性校验 + if db.query(DictItem).filter(and_(DictItem.name == data.name, DictItem.is_delete == 0)).first(): + return R.failed("字典项名称不能重复") + # 表单数据赋值给对象 + dict_data = DictItem(**data.model_dump(exclude={'id'})) + dict_data.create_user = get_user_id(request) + # 插入数据 + dict_data.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新字典项 + +def update_dict_item(request, data: DictItemForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + # 唯一性校验(排除自身) + if db.query(DictItem).filter(and_(DictItem.name == data.name, DictItem.id != data.id, DictItem.is_delete == 0)).first(): + return R.failed("字典项名称不能重复") + + # 根据ID查询记录 + dict_data = db.query(DictItem).filter(and_(DictItem.id == data.id, DictItem.is_delete == 0)).first() + # 查询结果判空 + if not dict_data: + return R.failed("记录不存在") + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + result = db.query(DictItem).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除字典项 + +def delete_dict_item(dict_id): + return DictItem.batch_delete_r(dict_id) +# 设置状态 + +def update_dict_item_status(request, data: DictItemStatusForm): + db = get_db_session() + # 根据ID查询记录 + dict_data = db.query(DictItem).filter(and_(DictItem.id == data.id, DictItem.is_delete == 0)).first() + # 查询结果判空 + if not dict_data: + return R.failed("记录不存在") + + # 更新记录 + result = db.query(DictItem).filter_by(id=data.id).update({ + "status": data.status, + "update_user": get_user_id(request), + "update_time": datetime.now(tz=timezone.utc) + }) + # 提交数据 + if not result: + return R.failed("设置失败") + # 返回结果 + return R.ok(msg="设置成功") diff --git a/apps/services/item.py b/apps/services/item.py new file mode 100644 index 0000000..9689923 --- /dev/null +++ b/apps/services/item.py @@ -0,0 +1,154 @@ +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + + +from apps.constants.constants import ITEM_TYPE_LIST +from apps.constants.message import PAGE_SIZE +from apps.forms.item import ItemForm +from apps.models.item import Item +from extensions import get_db_session +from utils import R +from utils.rich_text import get_file_url, save_file +from utils.request import like_contains +from utils.security import get_user_id + + +# 查询站点分页数据 + +def get_item_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(Item).filter(Item.is_delete == 0) + # 站点名称 + name = request.query_params.get('name') + if name: + query = query.filter(Item.name.like(like_contains(name), escape='\\')) + # 站点类型 + type = request.query_params.get('type') + if type: + query = query.filter(Item.type == type) + # 站点状态筛选 + status = request.query_params.get('status') + if status: + query = query.filter(Item.status == status) + # 排序 + query = query.order_by(Item.sort.asc()) + # 记录总数 + count = query.count() + # 分页查询 + item_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not item_list: + page = 1 + count = query.count() + item_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + # 遍历数据源 + if len(item_list) > 0: + for item in item_list: + # 对象转字典 + data = item.to_dict() + # 站点图片 + data['image'] = get_file_url(item.image) + # 站点类型描述 + data['type_name'] = ITEM_TYPE_LIST.get(item.type) + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据ID查询站点详情 + +def get_item_detail(item_id): + db = get_db_session() + # 根据ID查询站点 + item = db.query(Item).filter(and_(Item.id == item_id, Item.is_delete == 0)).first() + # 查询结果判空 + if not item: + return None + # 对象转字典 + data = item.to_dict() + # 站点图片 + data['image'] = get_file_url(item.image) + # 返回结果 + return data +# 添加站点 + +def add_item(request, data: ItemForm): + db = get_db_session() + # 唯一性校验 + if db.query(Item).filter(and_(Item.name == data.name, Item.is_delete == 0)).first(): + return R.failed("站点名称不能重复") + # 表单数据转字典 + form_data = data.model_dump(exclude={'id'}) + # 站点图片 + image = data.image + if image: + form_data['image'] = save_file(image, "item") + + # 表单数据赋值给对象 + item = Item(**form_data) + item.create_user = get_user_id(request) + # 插入数据 + item.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新站点 + +def update_item(request, data: ItemForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + # 唯一性校验(排除自身) + if db.query(Item).filter(and_(Item.name == data.name, Item.id != data.id, Item.is_delete == 0)).first(): + return R.failed("站点名称不能重复") + + # 根据ID查询记录 + item = db.query(Item).filter(and_(Item.id == data.id, Item.is_delete == 0)).first() + # 查询结果判空 + if not item: + return R.failed("记录不存在") + + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + # 站点图片 + image = data.image + if image: + updData['image'] = save_file(image, "item") + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + # 更新数据 + result = db.query(Item).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除站点 + +def delete_item(item_id): + return Item.batch_delete_r(item_id) +# 获取站点列表 + +def get_item_list(): + db = get_db_session() + item_rows = db.query(Item).filter(and_(Item.status == 1, Item.is_delete == 0)).order_by(Item.sort.asc()).all() + # 实例化站点列表 + item_list = [] + # 遍历数据源 + for v in item_rows: + # 对象转字典 + item = v.to_dict() + # 加入列表 + item_list.append(item) + # 返回结果 + return item_list diff --git a/apps/services/item_cate.py b/apps/services/item_cate.py new file mode 100644 index 0000000..607e1b8 --- /dev/null +++ b/apps/services/item_cate.py @@ -0,0 +1,171 @@ +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + + +from apps.constants.message import PAGE_SIZE +from apps.forms.item_cate import ItemCateForm +from apps.models.item_cate import ItemCate +from apps.services import item +from extensions import get_db_session +from utils import R +from utils.rich_text import get_file_url, save_file +from utils.request import like_contains +from utils.security import get_user_id + + +# 查询栏目数据 + +def get_item_cate_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(ItemCate).filter(ItemCate.is_delete == 0) + # 栏目名称 + name = request.query_params.get('name') + if name: + query = query.filter(ItemCate.name.like(like_contains(name), escape='\\')) + # 栏目状态筛选 + status = request.query_params.get('status') + if status: + query = query.filter(ItemCate.status == status) + # 排序 + query = query.order_by(ItemCate.sort.asc()) + # 记录总数 + count = query.count() + # 分页查询 + item_cate_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not item_cate_list: + page = 1 + count = query.count() + item_cate_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + # 遍历数据源 + if len(item_cate_list) > 0: + for v in item_cate_list: + # 对象转字典 + data = v.to_dict() + # 栏目图片 + data['cover'] = get_file_url(v.cover) if v.cover else None + # 查询站点信息 + item_info = item.get_item_detail(v.id) + if item_info: + data['item_name'] = item_info.get('name') if item_info else None + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据栏目ID查询详情 + +def get_item_cate_detail(itemcate_id): + db = get_db_session() + # 根据ID查询栏目 + cate = db.query(ItemCate).filter(and_(ItemCate.id == itemcate_id, ItemCate.is_delete == 0)).first() + # 查询结果判空 + if not cate: + return None + # 对象转字典 + data = cate.to_dict() + # 栏目图片 + if cate.cover: + data['cover'] = get_file_url(cate.cover) + # 返回结果 + return data +# 添加栏目 + +def add_item_cate(request, data: ItemCateForm): + db = get_db_session() + # 唯一性校验 + if db.query(ItemCate).filter(and_(ItemCate.name == data.name, ItemCate.is_delete == 0)).first(): + return R.failed("栏目名称不能重复") + # 表单数据转字典 + form_data = data.model_dump(exclude={'id'}) + + # 是否有封面 + is_cover = data.is_cover + if is_cover == 1: + # 栏目图片 + cover = data.cover + # 图片处理 + if cover: + form_data['cover'] = save_file(cover, "item_cate") + else: + form_data['cover'] = None + else: + form_data['cover'] = None + + # 表单数据赋值给对象 + cate = ItemCate(**form_data) + cate.create_user = get_user_id(request) + # 插入数据 + cate.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新栏目 + +def update_item_cate(request, data: ItemCateForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + # 唯一性校验(排除自身) + if db.query(ItemCate).filter(and_(ItemCate.name == data.name, ItemCate.id != data.id, ItemCate.is_delete == 0)).first(): + return R.failed("栏目名称不能重复") + + # 根据ID查询记录 + cate = db.query(ItemCate).filter(and_(ItemCate.id == data.id, ItemCate.is_delete == 0)).first() + # 查询结果判空 + if not cate: + return R.failed("记录不存在") + + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + # 是否有封面 + is_cover = data.is_cover + if is_cover == 1: + # 栏目图片 + cover = data.cover + # 图片处理 + if cover: + updData['cover'] = save_file(cover, "item_cate") + else: + updData['cover'] = None + else: + updData['cover'] = None + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + # 更新数据 + result = db.query(ItemCate).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除栏目 + +def delete_item_cate(cate_id): + return ItemCate.batch_delete_r(cate_id) +# 获取栏目列表 + +def get_cate_list(): + db = get_db_session() + cate_rows = db.query(ItemCate).filter(and_(ItemCate.is_delete == 0, ItemCate.status == 1)).order_by( + ItemCate.sort.asc()).all() + # 实例化栏目列表 + cate_list = [] + # 遍历数据源 + for v in cate_rows: + # 对象转字典 + item = v.to_dict() + # 加入列表 + cate_list.append(item) + # 返回结果 + return cate_list diff --git a/apps/services/job.py b/apps/services/job.py new file mode 100644 index 0000000..55be76b --- /dev/null +++ b/apps/services/job.py @@ -0,0 +1,259 @@ +import logging +from datetime import datetime, timezone + +from sqlalchemy import and_ + +from apps.constants.constants import JOB_STATUS_LIST, JOB_EXECUTE_POLICY_LIST +from apps.constants.message import PAGE_SIZE +from apps.forms.job import JobForm, JobStatusForm +from apps.models.job import Job +from apps.services.scheduler import ( + add_job_to_scheduler, + remove_job_from_scheduler, + pause_job_in_scheduler, + get_scheduler, + run_job_once as scheduler_run_job_once, + reload_scheduler, +) +from extensions import get_db_session +from utils import R +from utils.security import get_user_id +from utils.request import like_contains, parse_id_list + +logger = logging.getLogger(__name__) + + +# 查询定时任务分页数据 + +def get_job_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(Job).filter(Job.is_delete == 0) + # 任务名称 + job_name = request.query_params.get('jobName') + if job_name: + query = query.filter(Job.job_name.like(like_contains(job_name), escape='\\')) + # 任务状态 + status = request.query_params.get('status') + if status: + query = query.filter(Job.status == status) + # 任务分组 + job_group = request.query_params.get('jobGroup') + if job_group: + query = query.filter(Job.job_group == job_group) + # 排序 + query = query.order_by(Job.id.desc()) + # 记录总数 + count = query.count() + # 分页查询 + job_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not job_list: + page = 1 + count = query.count() + job_list = query.limit(limit).offset(0).all() + # 实例化结果 + scheduler = get_scheduler() + result = [] + if len(job_list) > 0: + for item in job_list: + data = item.to_dict() + data['status_name'] = JOB_STATUS_LIST.get(item.status) + data['execute_policy_name'] = JOB_EXECUTE_POLICY_LIST.get(item.execute_policy) + data['next_run_time'] = _get_next_run_time(scheduler, item.id) + result.append(data) + return R.ok(data=result, count=count) + + +def _get_next_run_time(scheduler, job_id): + """从调度器获取任务下次执行时间""" + try: + scheduler_job = scheduler.get_job(f"job_{job_id}") + if scheduler_job and scheduler_job.next_run_time: + return scheduler_job.next_run_time.strftime('%Y-%m-%d %H:%M:%S') + except Exception: + pass + return None + + +# 根据ID查询任务详情 + +def get_job_detail(job_id): + db = get_db_session() + job = db.query(Job).filter(and_(Job.id == job_id, Job.is_delete == 0)).first() + if not job: + return None + data = job.to_dict() + data['status_name'] = JOB_STATUS_LIST.get(job.status) + data['execute_policy_name'] = JOB_EXECUTE_POLICY_LIST.get(job.execute_policy) + return data + + +# 添加定时任务 + +def add_job(request, data: JobForm): + db = get_db_session() + # 表单数据赋值给对象 + job = Job(**data.model_dump(exclude={'id'})) + job.create_user = get_user_id(request) + # 插入数据 + job.save() + + # 状态为运行中且有Cron表达式,添加到调度器 + if job.status == 1 and job.cron_expression: + add_job_to_scheduler(job.to_dict()) + + return R.ok(msg="添加成功") + + +# 更新定时任务 + +def update_job(request, data: JobForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + + # 根据ID查询记录 + job = db.query(Job).filter(and_(Job.id == data.id, Job.is_delete == 0)).first() + if not job: + return R.failed("任务不存在") + + old_status = job.status + old_cron = job.cron_expression + + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + result = db.query(Job).filter_by(id=data.id).update(updData) + if not result: + return R.failed("更新失败") + + # 处理调度器更新 + _handle_scheduler_update(data.model_dump(), old_status, old_cron) + + return R.ok(msg="更新成功") + + +def _handle_scheduler_update(job_data, old_status, old_cron): + """处理调度器更新(job_data 为完整任务数据,含 execute_policy/params)""" + job_id = job_data.get('id') + new_status = job_data.get('status') + new_cron = job_data.get('cron_expression') + if old_status != 1 and new_status == 1 and new_cron: + add_job_to_scheduler(job_data) + elif old_status == 1 and new_status != 1: + remove_job_from_scheduler(job_id) + elif new_status == 1 and old_cron != new_cron: + if new_cron: + add_job_to_scheduler(job_data) + else: + remove_job_from_scheduler(job_id) + + +# 删除定时任务 + +def delete_job(job_id): + # 从调度器移除 + id_list = parse_id_list(job_id) + for jid in id_list: + remove_job_from_scheduler(jid) + return Job.batch_delete_r(job_id) + + +# 设置任务状态 + +def update_job_status(request, data: JobStatusForm): + db = get_db_session() + # 根据ID查询记录 + job = db.query(Job).filter(and_(Job.id == data.id, Job.is_delete == 0)).first() + if not job: + return R.failed("任务不存在") + + old_status = job.status + + # 更新记录 + result = db.query(Job).filter_by(id=data.id).update({ + "status": data.status, + "update_user": get_user_id(request), + "update_time": datetime.now(tz=timezone.utc) + }) + if not result: + return R.failed("设置失败") + + # 处理调度器 + if data.status == 1 and job.cron_expression: + job_dict = job.to_dict() + job_dict['status'] = data.status + add_job_to_scheduler(job_dict) + elif data.status == 2: + pause_job_in_scheduler(data.id) + elif old_status == 1 and data.status != 1: + remove_job_from_scheduler(data.id) + + return R.ok(msg="设置成功") + + +# 暂停任务 + +def pause_job_by_id(job_id): + db = get_db_session() + job = db.query(Job).filter(and_(Job.id == job_id, Job.is_delete == 0)).first() + if not job: + return R.failed("任务不存在") + + if job.status == 2: + return R.ok(msg="任务已处于暂停状态") + + job.status = 2 + job.update_user = job.create_user + job.update_time = datetime.now(tz=timezone.utc) + db.commit() + pause_job_in_scheduler(job_id) + + return R.ok(msg="暂停成功") + + +# 恢复任务 + +def resume_job_by_id(job_id): + db = get_db_session() + job = db.query(Job).filter(and_(Job.id == job_id, Job.is_delete == 0)).first() + if not job: + return R.failed("任务不存在") + + if job.status == 1: + return R.ok(msg="任务已处于运行中状态") + + if not job.cron_expression: + return R.failed("Cron表达式为空,无法恢复") + + job.status = 1 + job.update_user = job.create_user + job.update_time = datetime.now(tz=timezone.utc) + db.commit() + add_job_to_scheduler(job.to_dict()) + + return R.ok(msg="恢复成功") + + +# 立即执行一次 + +def run_job_once_api(job_id): + result = scheduler_run_job_once(job_id) + if result.get('success'): + return R.ok(data=result, msg="执行成功") + else: + return R.failed(msg=result.get('error', '执行失败')) + + +# 重新加载调度器 + +def reload_all_jobs(): + count = reload_scheduler() + return R.ok(data=count, msg=f"已重新加载 {count} 个任务") diff --git a/apps/services/job_executor.py b/apps/services/job_executor.py new file mode 100644 index 0000000..9dfb41d --- /dev/null +++ b/apps/services/job_executor.py @@ -0,0 +1,57 @@ +""" +任务执行器模块 + +根据触发器标识执行对应任务的功能。 +""" + +import logging +from typing import Any, Dict + +logger = logging.getLogger(__name__) + + +class JobExecutor: + """任务执行器:根据触发器标识调用对应的内部方法执行任务""" + + def __init__(self): + self._handlers = {} + self._register_default_handlers() + + def _register_default_handlers(self): + """注册默认的任务处理器""" + self.register('send_sms', self._send_sms) + self.register('send_email', self._send_email) + self.register('sync_data', self._sync_data) + + def register(self, trigger: str, handler): + """注册任务处理器""" + self._handlers[trigger] = handler + logger.info(f"注册任务处理器: {trigger}") + + def execute(self, trigger: str, **kwargs) -> Any: + """执行任务""" + if trigger not in self._handlers: + raise KeyError(f"未注册的任务触发器: {trigger}(可用 job_executor.register() 注册处理器)") + + handler = self._handlers[trigger] + logger.info(f"执行任务: {trigger}, 参数: {kwargs}") + return handler(**kwargs) + + def _send_sms(self, **kwargs) -> Dict: + """发送短信任务""" + logger.info(f"发送短信 [任务ID: {kwargs.get('job_id')}]") + return {'success': True, 'message': '短信发送成功'} + + def _send_email(self, **kwargs) -> Dict: + """发送邮件任务""" + logger.info(f"发送邮件 [任务ID: {kwargs.get('job_id')}]") + return {'success': True, 'message': '邮件发送成功'} + + def _sync_data(self, **kwargs) -> Dict: + """数据同步任务""" + logger.info(f"数据同步 [任务ID: {kwargs.get('job_id')}]") + return {'success': True, 'message': '数据同步成功'} + + +# 全局执行器实例 +job_executor = JobExecutor() diff --git a/apps/services/job_log.py b/apps/services/job_log.py new file mode 100644 index 0000000..a5bd341 --- /dev/null +++ b/apps/services/job_log.py @@ -0,0 +1,121 @@ +import logging + +from sqlalchemy import and_ + +from apps.constants.constants import JOB_LOG_STATUS_LIST +from apps.constants.message import PAGE_SIZE +from apps.models.job import Job +from apps.models.job_log import JobLog +from extensions import get_db_session +from extensions.sqlalchemy import SessionLocal +from utils import R +from utils.request import like_contains, parse_id_list + +logger = logging.getLogger(__name__) + + +# ============================================================ +# 日志写入(供调度器调用,可在请求上下文外执行) +# ============================================================ + +def record_job_log(job_id, start_time, end_time, status, exception_info=None, job_message=None): + """ + 记录任务执行日志 + + 使用独立会话写入,调度器线程等无请求上下文场景也可调用。 + """ + session = SessionLocal() + try: + job = session.query(Job).filter(Job.id == job_id, Job.is_delete == 0).first() + consume_time = 0 + if end_time and start_time: + consume_time = int((end_time - start_time).total_seconds() * 1000) + log = JobLog( + job_id=job_id, + job_name=job.job_name if job else '', + job_group=job.job_group if job else None, + job_trigger=job.job_trigger if job else None, + cron_expression=job.cron_expression if job else None, + job_message=job_message, + status=status, + start_time=start_time, + end_time=end_time, + consume_time=consume_time, + exception_info=exception_info, + ) + session.add(log) + session.commit() + return log + except Exception as e: + session.rollback() + logger.error(f"记录任务执行日志异常: {str(e)}") + return None + finally: + session.close() + + +# ============================================================ +# 日志查询/删除 +# ============================================================ + +def get_job_log_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(JobLog).filter(JobLog.del_flag == 0) + # 任务名称 + job_name = request.query_params.get('jobName') + if job_name: + query = query.filter(JobLog.job_name.like(like_contains(job_name), escape='\\')) + # 任务分组 + job_group = request.query_params.get('jobGroup') + if job_group: + query = query.filter(JobLog.job_group == job_group) + # 执行状态 + status = request.query_params.get('status') + if status: + query = query.filter(JobLog.status == status) + # 排序 + query = query.order_by(JobLog.id.desc()) + # 记录总数 + count = query.count() + # 分页查询 + log_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not log_list: + page = 1 + count = query.count() + log_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + for item in log_list: + data = item.to_dict() + data['status_name'] = JOB_LOG_STATUS_LIST.get(item.status) + result.append(data) + return R.ok(data=result, count=count) + + +# 根据ID查询日志详情 +def get_job_log_detail(job_log_id): + db = get_db_session() + log = db.query(JobLog).filter(and_(JobLog.id == job_log_id, JobLog.del_flag == 0)).first() + if not log: + return None + data = log.to_dict() + data['status_name'] = JOB_LOG_STATUS_LIST.get(log.status) + return data + + +# 删除任务日志(软删除) +def delete_job_log(ids_str): + id_list = parse_id_list(ids_str) + if not id_list: + return R.failed("记录ID不存在") + db = get_db_session() + result = db.query(JobLog).filter(JobLog.id.in_(id_list), JobLog.del_flag == 0).update( + {"del_flag": 1}, synchronize_session=False + ) + return R.ok(msg="本次共删除{0}条数据".format(result)) diff --git a/apps/services/level.py b/apps/services/level.py new file mode 100644 index 0000000..263076c --- /dev/null +++ b/apps/services/level.py @@ -0,0 +1,309 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 一米阳光团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称"本产品")拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== +import asyncio +import os +import random +import time +from datetime import datetime, timezone + +from fastapi.logger import logger +from openpyxl.reader.excel import load_workbook +from openpyxl.workbook import Workbook +from sqlalchemy import and_ + + +from apps.constants.message import PAGE_SIZE +from apps.forms.level import LevelForm, LevelStatusForm +from apps.models.level import Level +from apps.services import upload +from config import FASTAPI_TEMP_PATH, FASTAPI_UPLOAD_DIR, FASTAPI_IMAGE_URL +from extensions import get_db_session +from utils import R +from utils.file import mkdir + +from utils.security import get_user_id +from utils.request import like_contains + + +# 查询职级分页数据 + +def get_level_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 查询数据 + query = db.query(Level).filter(Level.is_delete == 0) + # 职级名称 + name = request.query_params.get('name') + if name: + query = query.filter(Level.name.like(like_contains(name), escape='\\')) + # 职级状态 + status = request.query_params.get('status') + if status: + query = query.filter(Level.status == status) + # 排序 + query = query.order_by(Level.sort.asc()) + # 记录总数 + count = query.count() + # 分页查询 + level_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not level_list: + page = 1 + count = query.count() + level_list = query.limit(limit).offset(0).all() + # 遍历数据源 + result = [] + if len(level_list) > 0: + for item in level_list: + # 对象转字典 + data = item.to_dict() + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据ID查询职级详情 + +def get_level_detail(level_id): + db = get_db_session() + # 根据ID查询职级 + level = db.query(Level).filter(and_(Level.id == level_id, Level.is_delete == 0)).first() + # 查询结果为空判断 + if not level: + return None + # 对象转字典 + data = level.to_dict() + # 返回结果 + return data +# 添加职级 + +def add_level(request, data: LevelForm): + db = get_db_session() + # 唯一性校验 + if db.query(Level).filter(and_(Level.name == data.name, Level.is_delete == 0)).first(): + return R.failed("职级名称不能重复") + # 表单数据赋值给对象 + level = Level(**data.model_dump(exclude={'id'})) + level.create_user = get_user_id(request) + # 插入数据 + level.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新职级 + +def update_level(request, data: LevelForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + # 唯一性校验(排除自身) + if db.query(Level).filter(and_(Level.name == data.name, Level.id != data.id, Level.is_delete == 0)).first(): + return R.failed("职级名称不能重复") + + # 根据ID查询记录 + level = db.query(Level).filter(and_(Level.id == data.id, Level.is_delete == 0)).first() + # 查询结果判空 + if not level: + return R.failed("记录不存在") + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + result = db.query(Level).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除职级 + +def delete_level(level_id): + return Level.batch_delete_r(level_id) +# 设置状态 + +def update_level_status(request, data: LevelStatusForm): + db = get_db_session() + # 根据ID查询记录 + level = db.query(Level).filter(and_(Level.id == data.id, Level.is_delete == 0)).first() + # 查询结果判空 + if not level: + return R.failed("记录不存在") + + # 更新记录 + result = db.query(Level).filter_by(id=data.id).update({ + "status": data.status, + "update_user": get_user_id(request), + "update_time": datetime.now(tz=timezone.utc) + }) + # 提交数据 + if not result: + return R.failed("设置失败") + # 返回结果 + return R.ok(msg="设置成功") +# 导入Excel + +async def import_level(request, file): + # 读取上传的文件 + result = await upload.upload_file(file, ".xls|.xlsx") + if result.get('code') != 0: + return result + # 本地文件地址 + relative_path = result['data']['filePath'] + path = FASTAPI_UPLOAD_DIR + relative_path + + def _import(): + try: + # 打开工作簿 + wb = load_workbook(path) + # 获取表单 + ws = wb.worksheets[0] + # 读取最大行 + max_row = ws.max_row + # 计数器 + count = 0 + # 遍历读取数据源 + for item in range(2, max_row + 1): + # 职级名称 + name = ws.cell(item, 2).value + # 职级排序 + sort = ws.cell(item, 3).value + # 职级状态 + status = ws.cell(item, 4).value + # 创建数据 + level = Level( + name=name, + status=1 if status == "正常" else 2, + sort=sort, + ) + level.create_user = get_user_id(request) + level.save() + + count += 1 + # 关闭工作簿 + wb.close() + return count + finally: + # 清理临时文件 + if os.path.exists(path): + os.remove(path) + + # Excel解析与同步DB写入放入线程池,避免阻塞事件循环 + count = await asyncio.to_thread(_import) + # 返回结果 + return R.ok(msg="本次共导入【{}】条数据".format(count)) + + +# 导出Excel + +def export_level(request): + db = get_db_session() + # 查询数据 + query = db.query(Level).filter(Level.is_delete == 0) + # 职级名称 + name = request.query_params.get('name') + if name: + query = query.filter(Level.name.like(like_contains(name), escape='\\')) + # 职级状态 + status = request.query_params.get('status') + if status: + query = query.filter(Level.status == status) + # 排序 + query = query.order_by(Level.sort.asc()) + # 导出数量限制,防止数据量过大导致内存溢出 + EXPORT_MAX_ROWS = 10000 + BATCH_SIZE = 500 + + # 实例化工作薄 + wb = Workbook() + # 指定第一个sheet + ws = wb.worksheets[0] + # 添加表头 + ws.append(['职级ID', '职级名称', '职级排序', '职级状态', '创建时间', '更新时间']) + + # yield_per 分批加载,边查边写,避免双倍内存占用 + for item in query.limit(EXPORT_MAX_ROWS).yield_per(BATCH_SIZE): + ws.append([ + item.id, item.name, item.sort, + "正常" if item.status == 1 else "禁用", + item.create_time.strftime('%Y-%m-%d %H:%M:%S') if item.create_time else None, + item.update_time.strftime('%Y-%m-%d %H:%M:%S') if item.update_time else None, + ]) + db.expunge(item) + + # 保存文件 + save_path = FASTAPI_TEMP_PATH + "/" + time.strftime('%Y%m%d') + # 创建存放目录 + mkdir(save_path) + # 定义文件名,年月日时分秒随机数 + file_name = time.strftime('%Y%m%d%H%M%S') + '%05d' % random.randint(0, 99999) + # 重写合成文件名 + file_name = os.path.join(file_name + ".xlsx") + # 拼接文件路径 + path = save_path + "/" + file_name + # 写入文件 + wb.save(r'' + path + '') + # 文件访问地址 + file_url = FASTAPI_IMAGE_URL + path.replace(FASTAPI_UPLOAD_DIR, "") + # 返回结果 + return R.ok(data=file_url) + + +# 获取职级数据列表 + +def get_level_list(): + db = get_db_session() + # 查询职级数据列表 + level_rows = db.query(Level).filter(and_(Level.is_delete == 0, Level.status == 1)).order_by(Level.sort.asc()).all() + # 实例化列表 + level_list = [] + # 遍历数据源 + if level_rows: + for v in level_rows: + # 对象转字典 + item = v.to_dict() + # 加入数组 + level_list.append(item) + # 返回结果 + return level_list diff --git a/apps/services/link.py b/apps/services/link.py new file mode 100644 index 0000000..8813034 --- /dev/null +++ b/apps/services/link.py @@ -0,0 +1,167 @@ +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + + +from apps.constants.constants import LINK_TYPE_LIST, LINK_PLATFORM_LIST, LINK_FORM_LIST +from apps.constants.message import PAGE_SIZE +from apps.forms.link import LinkForm, LinkStatusForm +from apps.models.link import Link +from extensions import get_db_session +from utils import R +from utils.rich_text import get_file_url, save_file +from utils.request import like_contains +from utils.security import get_user_id + + +# 查看友链分页数据 + +def get_link_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(Link).filter(Link.is_delete == 0) + # 友链名称 + name = request.query_params.get('name') + if name: + query = query.filter(Link.name.like(like_contains(name), escape='\\')) + # 友链类型:1-友情链接 2-合作伙伴 + type = request.query_params.get('type') + if type: + query = query.filter(Link.type == type) + # 投放平台:1PC站 2WAP站 3微信小程序 4APP应用 + platform = request.query_params.get('platform') + if platform: + query = query.filter(Link.platform == platform) + # 友链形式:1文字链接 2图片链接 + form = request.query_params.get('form') + if form: + query = query.filter(Link.form == form) + # 状态筛选 + status = request.query_params.get('status') + if status: + query = query.filter(Link.status == status) + # 排序 + query = query.order_by(Link.sort.desc()) + # 记录总数 + count = query.count() + # 分页查询 + link_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not link_list: + page = 1 + count = query.count() + link_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + # 遍历数据源 + if len(link_list) > 0: + for item in link_list: + # 对象转字典 + data = item.to_dict() + # 友链类型 + data['type_name'] = LINK_TYPE_LIST.get(item.type) + # 投放平台 + data['platform_name'] = LINK_PLATFORM_LIST.get(item.platform) + # 友链形式 + data['form_name'] = LINK_FORM_LIST.get(item.form) + # 友链图片 + data['image'] = get_file_url(item.image) if item.image else None + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据ID查看友链详情 + +def get_link_detail(link_id): + db = get_db_session() + # 根据ID查询友链 + link = db.query(Link).filter(and_(Link.id == link_id, Link.is_delete == 0)).first() + # 查询结果为空判断 + if not link: + return None + # 对象转字典 + data = link.to_dict() + # 友链图片 + data['image'] = get_file_url(link.image) if link.image else None + # 返回结果 + return data +# 添加友链 + +def add_link(request, data: LinkForm): + db = get_db_session() + # 表单数据转字典 + form_data = data.model_dump(exclude={'id'}, by_alias=True) + + # 友链图片 + image = data.image + if image: + form_data['image'] = save_file(image, "link") + + # 表单数据赋值给对象 + link = Link(**form_data) + link.create_user = get_user_id(request) + # 插入数据 + link.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新友链 + +def update_link(request, data: LinkForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + + # 根据ID查询记录 + link = db.query(Link).filter(and_(Link.id == data.id, Link.is_delete == 0)).first() + # 查询结果判空 + if not link: + return R.failed("记录不存在") + + # 构造更新数据(排除 id,使用别名以匹配数据库字段) + updData = data.model_dump(exclude={'id'}, by_alias=True) + # 友链图片 + image = data.image + if image: + updData['image'] = save_file(image, "link") + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + # 更新数据 + result = db.query(Link).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除友链 + +def delete_link(link_id): + return Link.batch_delete_r(link_id) +# 设置状态 + +def update_link_status(request, data: LinkStatusForm): + db = get_db_session() + # 根据ID查询记录 + link = db.query(Link).filter(and_(Link.id == data.id, Link.is_delete == 0)).first() + # 查询结果判空 + if not link: + return R.failed("记录不存在") + + # 更新记录 + result = db.query(Link).filter_by(id=data.id).update({ + "status": data.status, + "update_user": get_user_id(request), + "update_time": datetime.now(tz=timezone.utc) + }) + # 提交数据 + if not result: + return R.failed("设置失败") + # 返回结果 + return R.ok(msg="设置成功") diff --git a/apps/services/login.py b/apps/services/login.py new file mode 100644 index 0000000..1a6c02a --- /dev/null +++ b/apps/services/login.py @@ -0,0 +1,105 @@ +import asyncio + +from fastapi.logger import logger +from sqlalchemy import and_ + +from apps.forms.login import LoginForm +from apps.models.user import User +from apps.services import login_log +from extensions import get_db_session +from utils import R +from utils import password +from utils.jwt import create_token +from utils.captcha import verify_captcha +from utils.ip import get_client_ip +from config import LOGIN_FAIL_MAX, LOGIN_LOCK_SECONDS + +# Redis Key 前缀 +_LOGIN_FAIL_PREFIX = 'login:fail:' +_LOGIN_LOCK_PREFIX = 'login:lock:' + + +def _fail_key(ip: str, username: str) -> str: + """登录失败计数Key:按IP+用户名维度""" + return f'{_LOGIN_FAIL_PREFIX}{ip}:{username}' + + +def _lock_key(ip: str, username: str) -> str: + """登录锁定Key""" + return f'{_LOGIN_LOCK_PREFIX}{ip}:{username}' + + +async def _is_locked(redis, ip: str, username: str) -> tuple[bool, int]: + """检查是否处于锁定状态,返回 (是否锁定, 剩余秒数)""" + key = _lock_key(ip, username) + ttl = await redis.ttl(key) + if ttl and ttl > 0: + return True, ttl + return False, 0 + + +async def _record_fail(redis, ip: str, username: str): + """记录一次登录失败,达到上限则锁定""" + key = _fail_key(ip, username) + count = await redis.incr(key) + # 首次失败时设置过期时间(与锁定时间一致,避免残留) + if count == 1: + await redis.expire(key, LOGIN_LOCK_SECONDS) + # 达到上限,设置锁定 + if count >= LOGIN_FAIL_MAX: + lock_k = _lock_key(ip, username) + await redis.set(lock_k, '1', ex=LOGIN_LOCK_SECONDS) + logger.warning(f"登录锁定:IP={ip}, 用户={username}, 连续失败{count}次") + + +async def _clear_fail(redis, ip: str, username: str): + """登录成功后清除失败记录""" + fail_k = _fail_key(ip, username) + lock_k = _lock_key(ip, username) + await redis.delete(fail_k, lock_k) + + +# 系统登录 + +async def login(request, data: LoginForm): + # 获取客户端IP + ip = get_client_ip(request) + + # 检查是否被锁定 + from utils.redis_ctx import get_redis + redis = get_redis() + locked, remaining = await _is_locked(redis, ip, data.username) + if locked: + return R.failed(f"登录失败次数过多,请{remaining}秒后再试") + + # 验证码校验(验证成功后自动删除,防止重放攻击) + if not await verify_captcha(data.idKey, data.captcha): + return R.failed("验证码不正确") + + # 根据用户名查询用户信息(状态为1-正常,2-禁用),同步DB查询放入线程池 + def _query_user(): + db = get_db_session() + return db.query(User).filter(and_(User.username == data.username, User.is_delete == 0, User.status == 1)).first() + + user = await asyncio.to_thread(_query_user) + if not user: + # 记录失败但不透露具体原因(用户不存在、已禁用、密码错误统一提示) + await _record_fail(redis, ip, data.username) + await asyncio.to_thread(login_log.record_login_log, request, data.username, 1, 0, 1, error="用户名或密码不正确") + return R.failed("用户名或密码不正确") + + # 比对密码是否相同 + if not password.verify_password(data.password, user.salt or '', user.password or ''): + await _record_fail(redis, ip, data.username) + await asyncio.to_thread(login_log.record_login_log, request, data.username, 1, 0, 1, error="用户名或密码不正确") + return R.failed("用户名或密码不正确") + + # 登录成功,清除失败记录 + await _clear_fail(redis, ip, data.username) + + # 生成TOKEN + access_token = create_token({"userId": user.id, "username": user.username, "realname": user.realname or ''}) + # 记录登录成功日志 + await asyncio.to_thread(login_log.record_login_log, request, data.username, 1, 0, 0, result="登录成功") + # 返回结果 + return R.ok(msg="登录成功", data={"access_token": access_token}) diff --git a/apps/services/login_log.py b/apps/services/login_log.py new file mode 100644 index 0000000..f3060fc --- /dev/null +++ b/apps/services/login_log.py @@ -0,0 +1,131 @@ +import logging +import time + +from sqlalchemy import and_ + +from apps.constants.constants import LOG_TYPE_LIST, LOGIN_SOURCE_LIST, LOGIN_STATUS_LIST +from apps.constants.message import PAGE_SIZE +from apps.models.login_log import LoginLog +from extensions.sqlalchemy import commit_independently +from extensions import get_db_session +from utils import R +from utils.ip import get_client_ip +from utils.ip2region import get_ip_location +from utils.ua import parse_user_agent +from utils.request import like_contains, parse_id_list + +logger = logging.getLogger(__name__) + + +# ============================================================ +# 核心便捷方法 +# ============================================================ + +def record_login_log(request, username, log_type, source, status, **kwargs): + """ + 记录登录日志 + + 使用独立session写入,不受请求事务commit/rollback影响。 + 日志是审计数据,无论业务成功或失败都应持久化。 + + Args: + request: FastAPI Request 对象 + username: 登录账号 + log_type: 日志类型(1-登录,2-退出) + source: 登录来源(0-后台,1-前台) + status: 操作状态(0-正常,1-异常) + **kwargs: 其他可选参数 + - method: 请求方法 + - request_method: 请求方式 + - url: 请求URL + - param: 请求参数 + - result: 返回结果 + - consume_time: 请求耗时(毫秒) + - error: 错误信息 + """ + try: + # 从 request 提取信息 + ip = get_client_ip(request) + location = get_ip_location(ip) + ua = request.headers.get('User-Agent', '') + os_name, browser_name = parse_user_agent(ua) + + log = LoginLog( + username=username, + log_type=log_type, + source=source, + method=kwargs.get('method', request.method), + request_method=kwargs.get('request_method', ''), + url=kwargs.get('url', str(request.url)), + param=kwargs.get('param', ''), + result=kwargs.get('result', ''), + ip=ip, + location=location, + os=os_name, + browser=browser_name, + consume_time=kwargs.get('consume_time', 0), + status=status, + error=kwargs.get('error', ''), + ) + + commit_independently(log) + return log + + except Exception as e: + logger.error(f"记录登录日志异常: {str(e)}") + return None + + +# ============================================================ +# CRUD 服务 +# ============================================================ + +def get_login_log_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(LoginLog).filter(LoginLog.is_delete == 0) + # 登录账号 + username = request.query_params.get('username') + if username: + query = query.filter(LoginLog.username.like(like_contains(username), escape='\\')) + # 日志类型 + log_type = request.query_params.get('log_type') + if log_type: + query = query.filter(LoginLog.log_type == log_type) + # 登录来源 + source = request.query_params.get('source') + if source: + query = query.filter(LoginLog.source == source) + # 操作状态 + status = request.query_params.get('status') + if status: + query = query.filter(LoginLog.status == status) + # 排序 + query = query.order_by(LoginLog.id.desc()) + # 记录总数 + count = query.count() + # 分页查询 + log_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not log_list: + page = 1 + count = query.count() + log_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + if len(log_list) > 0: + for item in log_list: + data = item.to_dict() + data['log_type_name'] = LOG_TYPE_LIST.get(item.log_type) + data['source_name'] = LOGIN_SOURCE_LIST.get(item.source) + data['status_name'] = LOGIN_STATUS_LIST.get(item.status) + result.append(data) + return R.ok(data=result, count=count) + + +def delete_login_log(ids_str): + return LoginLog.batch_delete_r(ids_str) diff --git a/apps/services/member.py b/apps/services/member.py new file mode 100644 index 0000000..7392691 --- /dev/null +++ b/apps/services/member.py @@ -0,0 +1,231 @@ +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + +from apps.constants.constants import GENDER_LIST, MEMBER_SOURCE_LIST +from apps.constants.message import PAGE_SIZE +from apps.forms.member import MemberForm, MemberStatusForm +from apps.models.member import Member +from apps.models.member_level import MemberLevel +from extensions import get_db_session +from utils import R +from utils import password +from utils.rich_text import get_file_url, save_file +from utils.request import like_contains +from utils.security import get_user_id + + +# 查询会员分页数据 + +def get_member_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(Member).filter(Member.is_delete == 0) + # 会员账号 + username = request.query_params.get('username') + if username: + query = query.filter(Member.username == username) + # 注册来源:1-网站注册 2-客户端注册 3-小程序注册 4-手机站注册 5-后台添加 + source = request.query_params.get('source') + if source: + query = query.filter(Member.source == source) + # 性别 + gender = request.query_params.get('gender') + if gender: + query = query.filter(Member.gender == gender) + # 会员状态 + status = request.query_params.get('status') + if status: + query = query.filter(Member.status == status) + # 排序 + query = query.order_by(Member.id.desc()) + # 记录总数 + count = query.count() + # 分页查询 + member_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not member_list: + page = 1 + count = query.count() + member_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + # 遍历数据源 + if len(member_list) > 0: + # 仅查询当前页用到的会员等级ID + level_ids = {item.member_level for item in member_list if item.member_level} + member_level_list = {} + if level_ids: + for ml in db.query(MemberLevel).filter(MemberLevel.id.in_(level_ids), MemberLevel.is_delete == 0).all(): + member_level_list[ml.id] = ml.name + + # 遍历会员数据源 + for item in member_list: + # 对象转字典 + data = item.to_dict() + # 性别 + data['gender_name'] = GENDER_LIST.get(item.gender) + # 头像 + data['avatar'] = get_file_url(item.avatar) if item.avatar else "" + # 会员等级描述 + data['member_level_name'] = member_level_list.get(item.member_level) if member_level_list else "" + # 会员来源描述 + data['source_name'] = MEMBER_SOURCE_LIST.get(item.source) + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据ID查看会员详情 + +def get_member_detail(member_id): + db = get_db_session() + # 根据ID查询会员 + member = db.query(Member).filter(and_(Member.id == member_id, Member.is_delete == 0)).first() + # 查询结果判空 + if not member: + return None + + # 城市编码 + cityList = [] + # 省份编号 + cityList.append(member.province_code) + # 城市编码 + cityList.append(member.city_code) + # 县区编码 + cityList.append(member.district_code) + + # 对象转字典 + data = member.to_dict() + # 头像 + data['avatar'] = get_file_url(member.avatar) if member.avatar else "" + # 行政区划 + data['city'] = cityList + # 返回结果 + return data +# 添加会员 + +def add_member(request, data: MemberForm): + db = get_db_session() + # 唯一性校验 + if db.query(Member).filter(and_(Member.username == data.username, Member.is_delete == 0)).first(): + return R.failed("登录账号不能重复") + # 获取表单数据 + form_data = data.model_dump(exclude={'id'}) + + # 图片处理 + avatar = form_data.get('avatar') + if avatar: + form_data['avatar'] = save_file(avatar, "member") + + # 密码加密 + pwd = form_data.get('password') + if pwd: + salt = password.generate_salt() + form_data['password'] = password.encrypt_password(pwd, salt) + form_data['salt'] = salt + + # 行政区划处理 + citys = form_data.get('city') + # 从表单中移除行政区划 + del form_data['city'] + if citys and len(citys) == 3: + # 省份编码 + form_data['province_code'] = citys[0] + # 城市编码 + form_data['city_code'] = citys[1] + # 县区编码 + form_data['district_code'] = citys[2] + + # 表单数据赋值给对象 + member = Member(**form_data) + member.create_user = get_user_id(request) + # 插入数据 + member.save() + + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新会员 + +def update_member(request, data: MemberForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + # 唯一性校验(排除自身) + if db.query(Member).filter(and_(Member.username == data.username, Member.id != data.id, Member.is_delete == 0)).first(): + return R.failed("登录账号不能重复") + + # 根据ID查询记录 + user = db.query(Member).filter(and_(Member.id == data.id, Member.is_delete == 0)).first() + # 查询结果判空 + if not user: + return R.failed("记录不存在") + + # 获取表单数据(排除密码字段,避免None覆盖原值) + form_data = data.model_dump(exclude={'id', 'password'}) + + # 图片处理 + avatar = form_data.get('avatar') + if avatar: + form_data['avatar'] = save_file(avatar, "member") + + # 密码加密:仅传入新密码时才更新 + pwd = data.password + if pwd: + salt = password.generate_salt() + form_data['password'] = password.encrypt_password(pwd, salt) + form_data['salt'] = salt + + # 行政区划处理 + citys = form_data.get('city') + # 从表单中移除行政区划 + del form_data['city'] + if citys and len(citys) == 3: + # 省份编码 + form_data['province_code'] = citys[0] + # 城市编码 + form_data['city_code'] = citys[1] + # 县区编码 + form_data['district_code'] = citys[2] + + form_data['update_user'] = get_user_id(request) + form_data['update_time'] = datetime.now(tz=timezone.utc) + # 更新数据 + result = db.query(Member).filter_by(id=data.id).update(form_data) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除会员 + +def delete_member(member_id): + return Member.batch_delete_r(member_id) +# 设置状态 + +def update_member_status(request, data: MemberStatusForm): + db = get_db_session() + # 根据ID查询记录 + user = db.query(Member).filter(and_(Member.id == data.id, Member.is_delete == 0)).first() + # 查询结果判空 + if not user: + return R.failed("记录不存在") + + # 更新记录 + result = db.query(Member).filter_by(id=data.id).update({ + "status": data.status, + "update_user": get_user_id(request), + "update_time": datetime.now(tz=timezone.utc) + }) + # 提交数据 + if not result: + return R.failed("设置失败") + # 返回结果 + return R.ok(msg="设置成功") diff --git a/apps/services/member_level.py b/apps/services/member_level.py new file mode 100644 index 0000000..4da6afb --- /dev/null +++ b/apps/services/member_level.py @@ -0,0 +1,125 @@ +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + +from apps.constants.message import PAGE_SIZE +from apps.forms.member_level import MemberLevelForm +from apps.models.member_level import MemberLevel +from extensions import get_db_session +from utils import R +from utils.security import get_user_id +from utils.request import like_contains + + +# 查询会员登记分页数据 + +def get_member_level_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(MemberLevel).filter(MemberLevel.is_delete == 0) + # 会员等级名称 + name = request.query_params.get('name') + if name: + query = query.filter(MemberLevel.name.like(like_contains(name), escape='\\')) + # 排序 + query = query.order_by(MemberLevel.sort.asc()) + # 记录总数 + count = query.count() + # 分页查询 + member_level_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not member_level_list: + page = 1 + count = query.count() + member_level_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + # 遍历数据源 + if len(member_level_list) > 0: + for item in member_level_list: + # 对象转字典 + data = item.to_dict() + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据会员等级ID查询详情 + +def get_member_level_detail(member_level_id): + db = get_db_session() + # 根据ID查询会员等级 + member_level = db.query(MemberLevel).filter( + and_(MemberLevel.id == member_level_id, MemberLevel.is_delete == 0)).first() + # 查询结果判空 + if not member_level: + return None + # 对象转字典 + data = member_level.to_dict() + # 返回结果 + return data +# 添加会员等级 + +def add_member_level(request, data: MemberLevelForm): + db = get_db_session() + # 唯一性校验 + if db.query(MemberLevel).filter(and_(MemberLevel.name == data.name, MemberLevel.is_delete == 0)).first(): + return R.failed("会员等级名称不能重复") + # 表单数据赋值给对象 + member_level = MemberLevel(**data.model_dump(exclude={'id'})) + member_level.create_user = get_user_id(request) + # 插入数据 + member_level.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新会员等级 + +def update_member_level(request, data: MemberLevelForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + # 唯一性校验(排除自身) + if db.query(MemberLevel).filter(and_(MemberLevel.name == data.name, MemberLevel.id != data.id, MemberLevel.is_delete == 0)).first(): + return R.failed("会员等级名称不能重复") + + # 根据ID查询记录 + member_level = db.query(MemberLevel).filter(and_(MemberLevel.id == data.id, MemberLevel.is_delete == 0)).first() + # 查询结果判空 + if not member_level: + return R.failed("记录不存在") + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + result = db.query(MemberLevel).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除会员等级 + +def delete_member_level(member_level_id): + return MemberLevel.batch_delete_r(member_level_id) +# 获取会员等级列表 + +def get_member_level_list(): + db = get_db_session() + member_level_list = db.query(MemberLevel).filter(MemberLevel.is_delete == 0).order_by(MemberLevel.sort.asc()).all() + # 实例化对象 + result = [] + # 遍历数据源 + for v in member_level_list: + # 对象转字典 + item = v.to_dict() + # 加入列表 + result.append(item) + # 返回结果 + return result diff --git a/apps/services/menu.py b/apps/services/menu.py new file mode 100644 index 0000000..0b8932d --- /dev/null +++ b/apps/services/menu.py @@ -0,0 +1,449 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 一米阳光团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +import asyncio +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_, or_ + + +from apps.forms.menu import MenuForm +from apps.models.menu import Menu +from apps.models.role_menu import RoleMenu +from apps.models.user_role import UserRole +from extensions import get_db_session +from utils import R +from utils.security import get_user_id +from utils.request import like_contains, parse_id_list +from utils.perm_cache import get_cached_permissions, set_cached_permissions, get_cached_menus, set_cached_menus, invalidate_menu_change + + +# 查询菜单列表 + +def get_menu_page(request): + db = get_db_session() + # 实例化查询对象 + query = db.query(Menu).filter(Menu.is_delete == 0) + # 菜单名称 + name = request.query_params.get('name') + if name: + # 菜单名称模糊查询 + query = query.filter(Menu.name.like(like_contains(name), escape='\\')) + # 查询数据 + menu_list = query.order_by(Menu.sort.asc()).all() + # 实例化数组对象 + result = [] + # 遍历数据源 + if menu_list: + for item in menu_list: + # 对象转字典 + data = item.to_dict() + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result) +# 根据ID查询菜单详情 + +def get_menu_detail(menu_id): + db = get_db_session() + # 根据ID查询菜单 + menu = db.query(Menu).filter(and_(Menu.id == menu_id, Menu.is_delete == 0)).first() + # 查询结果判空 + if not menu: + return None + # 对象转字典 + data = menu.to_dict() + + # 查询菜单权限节点 + permission_list = db.query(Menu).filter( + and_(Menu.parent_id == menu_id, Menu.type == 1, Menu.is_delete == 0)).all() + # 选中的权限节点 + checked_list = [] + if permission_list: + for v in permission_list: + checked_list.append(v.sort) + # 权限节点 + data['checked_list'] = checked_list + # 返回结果 + return data +# 添加菜单 + +async def add_menu(request, data: MenuForm): + def _add(): + db = get_db_session() + # 权限节点 + checkedList = data.checked_list or [] + + # 表单数据赋值给对象 + menu = Menu( + title=data.title, + icon=data.icon, + path=data.path, + parent_id=data.parent_id, + type=data.type, + permission=data.permission, + component=data.component, + status=data.status, + hide=data.hide, + target=data.target, + sort=data.sort + ) + menu.create_user = get_user_id(request) + # 插入数据 + menu.save() + + # 保存节点数据 + set_permission(data.type, checkedList, data.title, data.path, menu.id, get_user_id(request)) + + # 同步DB操作放入线程池,避免阻塞事件循环 + await asyncio.to_thread(_add) + + # 清理权限缓存 + await invalidate_menu_change() + + # 返回结果 + return R.ok(msg="添加成功") +# 更新菜单 + +async def update_menu(request, data: MenuForm): + def _update(): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + + # 根据ID查询记录 + menu = db.query(Menu).filter(and_(Menu.id == data.id, Menu.is_delete == 0)).first() + # 查询结果判空 + if not menu: + return R.failed("记录不存在") + + # 权限节点 + checkedList = data.checked_list or [] + + # 更新数据源(排除 id 和 checked_list) + updData = data.model_dump(exclude={'id', 'checked_list'}) + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + # 更新数据源 + result = db.query(Menu).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + + # 保存节点数据 + set_permission(data.type, checkedList, data.title, data.path, menu.id, get_user_id(request)) + return None + + # 同步DB操作放入线程池,避免阻塞事件循环 + err = await asyncio.to_thread(_update) + if err: + return err + + # 清理权限缓存 + await invalidate_menu_change() + + # 返回结果 + return R.ok(msg="更新成功") +# 删除菜单 + +async def delete_menu(menu_id): + def _delete(): + db = get_db_session() + # 记录ID为空判断 + if not menu_id: + return R.failed("记录ID不存在"), None + # 分裂字符串 + id_list = parse_id_list(menu_id) + if not id_list: + return R.failed("记录ID不存在"), None + # 批量查询并校验 + records = db.query(Menu).filter(and_(Menu.id.in_(id_list), Menu.is_delete == 0)).all() + if len(records) != len(id_list): + return R.failed("记录不存在"), None + # 批量设置删除标识 + for record in records: + record.is_delete = 1 + return None, records + + # 同步DB操作放入线程池,避免阻塞事件循环 + err, records = await asyncio.to_thread(_delete) + if err: + return err + + # 清理权限缓存 + await invalidate_menu_change() + # 返回结果 + return R.ok(msg="本次共删除{0}条数据".format(len(records))) +# 保存节点数据 + +def set_permission(menu_type, checkedList, title, path, parent_id, user_id): + db = get_db_session() + # 参数判空 + if menu_type != 0 or not checkedList or path == "": + return + # 删除现有节点 + db.query(Menu).filter(and_(Menu.parent_id == parent_id, Menu.is_delete == 0)).delete() + # 模块名称 + moduleTitle = title.replace("管理", "") + # 请求path处理 + pathArr = path.split('/') + if len(pathArr) < 3: + return + # 模块名 + moduleName = pathArr[len(pathArr) - 1] + # 遍历数据源 + for v in checkedList: + # 实例化菜单对象 + entity = Menu() + # 节点值 + value = int(v) + if value == 1: + # 查询 + entity.title = "查询" + moduleTitle + entity.path = "/" + moduleName + "/list" + entity.permission = "sys:" + moduleName + ":list" + elif value == 5: + # 添加 + entity.title = "添加" + moduleTitle + entity.path = "/" + moduleName + "/add" + entity.permission = "sys:" + moduleName + ":add" + elif value == 10: + # 修改 + entity.title = "修改" + moduleTitle + entity.path = "/" + moduleName + "/update" + entity.permission = "sys:" + moduleName + ":update" + elif value == 15: + # 删除 + entity.title = "删除" + moduleTitle + entity.path = "/" + moduleName + "/delete" + entity.permission = "sys:" + moduleName + ":delete" + elif value == 20: + # 详情 + entity.title = moduleTitle + "详情" + entity.path = "/" + moduleName + "/detail" + entity.permission = "sys:" + moduleName + ":detail" + elif value == 25: + # 设置状态 + entity.title = "设置状态" + entity.path = "/" + moduleName + "/status" + entity.permission = "sys:" + moduleName + ":status" + elif value == 30: + # 批量删除 + entity.title = "批量删除" + entity.path = "/" + moduleName + "/dall" + entity.permission = "sys:" + moduleName + ":dall" + elif value == 35: + # 添加子级 + entity.title = "添加子级" + entity.path = "/" + moduleName + "/addz" + entity.permission = "sys:" + moduleName + ":addz" + elif value == 40: + # 全部展开 + entity.title = "全部展开" + entity.path = "/" + moduleName + "/expand" + entity.permission = "sys:" + moduleName + ":expand" + elif value == 45: + # 全部折叠 + entity.title = "全部折叠" + entity.path = "/" + moduleName + "/collapse" + entity.permission = "sys:" + moduleName + ":collapse" + elif value == 50: + # 导出数据 + entity.title = "导出" + moduleTitle + entity.path = "/" + moduleName + "/export" + entity.permission = "sys:" + moduleName + ":export" + elif value == 55: + # 导入数据 + entity.title = "导入" + moduleTitle + entity.path = "/" + moduleName + "/import" + entity.permission = "sys:" + moduleName + ":import" + elif value == 60: + # 分配权限 + entity.title = "分配权限" + entity.path = "/" + moduleName + "/permission" + entity.permission = "sys:" + moduleName + ":permission" + elif value == 65: + # 重置密码 + entity.title = "重置密码" + entity.path = "/" + moduleName + "/resetPwd" + entity.permission = "sys:" + moduleName + ":resetPwd" + + # 设置默认值 + entity.parent_id = parent_id + entity.type = 1 + entity.status = 1 + entity.target = "_self" + entity.sort = value + entity.create_user = user_id + # 插入数据 + entity.save() + + +# 获取用户权限节点 + +def get_permissions_list_sync(user_id): + """同步查询用户权限节点(直查DB,不读缓存),供同步鉴权路径使用""" + db = get_db_session() + if user_id == 1: + # 超级管理员 + menu_list = db.query(Menu).filter(and_(Menu.is_delete == 0, Menu.type == 1)).all() + else: + # 其他用户:通过 ORM join 查询权限节点 + menu_list = ( + db.query(Menu) + .join(RoleMenu, RoleMenu.menu_id == Menu.id) + .join(UserRole, UserRole.role_id == RoleMenu.role_id) + .filter( + and_( + UserRole.user_id == user_id, + or_(Menu.type == 1, and_(Menu.type == 0, Menu.permission != "")), + Menu.status == 1, + Menu.is_delete == 0, + ) + ) + .all() + ) + return [item.permission for item in (menu_list or [])] + + +async def get_permissions_list(user_id): + # 尝试读取缓存 + cached = await get_cached_permissions(user_id) + if cached is not None: + return cached + + permission_list = await asyncio.to_thread(get_permissions_list_sync, user_id) + + # 写入缓存 + await set_cached_permissions(user_id, permission_list) + return permission_list + + +# 根据用户ID查询菜单列表 + +def get_permission_menu_list_sync(user_id): + """同步查询用户菜单树(直查DB,不读缓存),供线程池内使用""" + db = get_db_session() + if user_id == 1: + # 超级管理员 + # 查询全部菜单列表 + menu_list = db.query(Menu).filter(and_(Menu.is_delete == 0, Menu.status == 1, Menu.type == 0)).order_by( + Menu.sort.asc()) + return get_tree_list(menu_list) + # 其他用户:通过 ORM join 查询菜单列表 + menu_list = ( + db.query(Menu) + .join(RoleMenu, RoleMenu.menu_id == Menu.id) + .join(UserRole, UserRole.role_id == RoleMenu.role_id) + .filter( + and_( + UserRole.user_id == user_id, + Menu.type == 0, + Menu.status == 1, + Menu.is_delete == 0, + ) + ) + .order_by(Menu.sort.asc()) + .all() + ) + return get_tree_list(menu_list) + + +async def get_permission_menu_list(user_id): + # 尝试读取缓存 + cached = await get_cached_menus(user_id) + if cached is not None: + return cached + + menu_list = await asyncio.to_thread(get_permission_menu_list_sync, user_id) + + # 写入缓存 + await set_cached_menus(user_id, menu_list) + return menu_list + + +# 根据数据源获取树状结构 + +def get_tree_list(source_list): + # 实例化数组 + menu_list = [] + if source_list: + for item in source_list: + data = { + 'id': item.id, + 'title': item.title, + 'icon': item.icon, + 'path': item.path, + 'parent_id': item.parent_id, + 'type': item.type, + 'component': item.component, + 'permission': item.permission, + 'target': item.target, + 'hide': item.hide, + } + menu_list.append(data) + # 处理数据源为树状结构 + result = get_tree(menu_list, 0) + # 返回结果 + return result + + +# 获取树状结构 + +def get_tree(data, parent_id): + # 按 parent_id 建立索引,O(n) 构建 + O(n) 遍历 + children_map = {} + for item in data: + pid = item["parent_id"] + if pid not in children_map: + children_map[pid] = [] + children_map[pid].append(item) + + def build(pid): + result = [] + for item in children_map.get(pid, []): + item["children"] = build(item["id"]) + result.append(item) + return result + + return build(parent_id) diff --git a/apps/services/notice.py b/apps/services/notice.py new file mode 100644 index 0000000..c97cddf --- /dev/null +++ b/apps/services/notice.py @@ -0,0 +1,138 @@ +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + +from apps.constants.constants import NOTICE_SOURCE_LIST +from apps.constants.message import PAGE_SIZE +from apps.forms.notice import NoticeForm +from apps.models.notice import Notice +from config import FASTAPI_IMAGE_URL +from extensions import get_db_session +from utils import R +from utils.rich_text import save_content +from utils.request import like_contains +from utils.security import get_user_id + + +# 查询通知公告分页数据 + +def get_notice_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(Notice).filter(Notice.is_delete == 0) + # 通知公告标题 + title = request.query_params.get('title') + if title: + query = query.filter(Notice.title.like(like_contains(title), escape='\\')) + # 通知来源:1官方平台 2开源中国 3CSDN官方 4新浪微博 + source = request.query_params.get('source') + if source: + query = query.filter(Notice.source == source) + # 通知状态:1-正常 2-停用 + status = request.query_params.get('status') + if status: + query = query.filter(Notice.status == status) + # 排序 + query = query.order_by(Notice.id.desc()) + # 记录总数 + count = query.count() + # 分页查询 + notice_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not notice_list: + page = 1 + count = query.count() + notice_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + # 遍历数据源 + if len(notice_list) > 0: + for item in notice_list: + # 对象转字典 + data = item.to_dict() + # 通知来源描述 + data['source_name'] = NOTICE_SOURCE_LIST.get(item.source) + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据通知ID查询详情 + +def get_notice_detail(notice_id): + db = get_db_session() + # 根据ID查询通知公告 + notice = db.query(Notice).filter(and_(Notice.id == notice_id, Notice.is_delete == 0)).first() + # 查询结果判空 + if not notice: + return None + # 对象转字典 + data = notice.to_dict() + # 富文本内容 + content = (notice.content or "").replace("[IMG_URL]", FASTAPI_IMAGE_URL) + data['content'] = content + # 通知来源 + data['source_name'] = NOTICE_SOURCE_LIST.get(notice.source) + # 返回结果 + return data +# 添加通知公告 + +def add_notice(request, data: NoticeForm): + db = get_db_session() + # 通知标题 + title = data.title + # 通知内容 + content = data.content + # 处理富文本内容 + content = save_content(content, title, "notice") + + # 表单数据赋值给对象 + notice = Notice(**data.model_dump(exclude={'id'})) + notice.content = content + notice.create_user = get_user_id(request) + # 插入数据 + notice.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新通知公告 + +def update_notice(request, data: NoticeForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + + # 根据ID查询记录 + notice = db.query(Notice).filter(and_(Notice.id == data.id, Notice.is_delete == 0)).first() + # 查询结果判空 + if not notice: + return R.failed("记录不存在") + + # 通知标题 + title = data.title + # 通知内容 + content = data.content + # 处理富文本内容 + content = save_content(content, title, "notice") + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + updData['content'] = content + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + # 更新记录 + result = db.query(Notice).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除通知公告 + +def delete_notice(notice_id): + return Notice.batch_delete_r(notice_id) diff --git a/apps/services/operation_log.py b/apps/services/operation_log.py new file mode 100644 index 0000000..c9cc8e2 --- /dev/null +++ b/apps/services/operation_log.py @@ -0,0 +1,117 @@ +import logging + +from sqlalchemy import and_ + +from apps.constants.constants import OPERATION_SOURCE_LIST, OPERATION_STATUS_LIST +from apps.constants.message import PAGE_SIZE +from apps.models.operation_log import OperationLog +from extensions.sqlalchemy import commit_independently +from extensions import get_db_session +from utils import R +from utils.ip import get_client_ip +from utils.ip2region import get_ip_location +from utils.ua import parse_user_agent +from utils.request import like_contains, parse_id_list + +logger = logging.getLogger(__name__) + + +def record_operation_log(request, title, **kwargs): + """ + 记录操作日志 + + 使用独立session写入,不受请求事务commit/rollback影响。 + 日志是审计数据,无论业务成功或失败都应持久化。 + + Args: + request: FastAPI Request 对象 + title: 操作标题 + **kwargs: 其他可选参数 + - type: 请求类型 + - source: 请求来源(默认0-后台) + - method: 请求方法 + - request_method: 请求方式 + - url: 请求URL + - param: 请求参数 + - result: 请求结果 + - consume_time: 请求耗时(毫秒) + - status: 操作状态(0-正常,1-异常) + - error: 错误描述 + """ + try: + # 从 request 提取信息 + ip = get_client_ip(request) + location = get_ip_location(ip) + ua = request.headers.get('User-Agent', '') + os_name, browser_name = parse_user_agent(ua) + + log = OperationLog( + title=title, + type=kwargs.get('type', 0), + source=kwargs.get('source', 0), + method=kwargs.get('method', ''), + request_method=kwargs.get('request_method', request.method), + url=kwargs.get('url', str(request.url)), + param=kwargs.get('param', ''), + result=kwargs.get('result', ''), + ip=ip, + location=location, + os=os_name, + browser=browser_name, + consume_time=kwargs.get('consume_time', 0), + status=kwargs.get('status', 0), + error=kwargs.get('error', ''), + ) + + commit_independently(log) + return log + + except Exception as e: + logger.error(f"记录操作日志异常: {str(e)}") + return None + + +def get_operation_log_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(OperationLog).filter(OperationLog.is_delete == 0) + # 请求标题 + title = request.query_params.get('title') + if title: + query = query.filter(OperationLog.title.like(like_contains(title), escape='\\')) + # 请求方式 + request_method = request.query_params.get('request_method') + if request_method: + query = query.filter(OperationLog.request_method == request_method) + # 操作状态 + status = request.query_params.get('status') + if status: + query = query.filter(OperationLog.status == status) + # 排序 + query = query.order_by(OperationLog.id.desc()) + # 记录总数 + count = query.count() + # 分页查询 + log_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not log_list: + page = 1 + count = query.count() + log_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + if len(log_list) > 0: + for item in log_list: + data = item.to_dict() + data['source_name'] = OPERATION_SOURCE_LIST.get(item.source) + data['status_name'] = OPERATION_STATUS_LIST.get(item.status) + result.append(data) + return R.ok(data=result, count=count) + + +def delete_operation_log(ids_str): + return OperationLog.batch_delete_r(ids_str) diff --git a/apps/services/position.py b/apps/services/position.py new file mode 100644 index 0000000..55147a1 --- /dev/null +++ b/apps/services/position.py @@ -0,0 +1,152 @@ +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + +from apps.constants.message import PAGE_SIZE +from apps.forms.position import PositionForm, PositionStatusForm +from apps.models.position import Position +from extensions import get_db_session +from utils import R +from utils.security import get_user_id +from utils.request import like_contains + + +# 查询岗位分页数据 + +def get_position_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(Position).filter(Position.is_delete == 0) + # 岗位名称 + name = request.query_params.get('name') + if name: + query = query.filter(Position.name.like(like_contains(name), escape='\\')) + # 岗位状态筛选 + status = request.query_params.get('status') + if status: + query = query.filter(Position.status == status) + # 排序 + query = query.order_by(Position.sort.asc()) + # 记录总数 + count = query.count() + # 分页查询 + position_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not position_list: + page = 1 + count = query.count() + position_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + # 遍历数据源 + if len(position_list) > 0: + for item in position_list: + # 对象转字典 + data = item.to_dict() + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据ID查询岗位详情 + +def get_position_detail(position_id): + db = get_db_session() + # 根据ID查询岗位 + position = db.query(Position).filter(and_(Position.id == position_id, Position.is_delete == 0)).first() + # 查询结果判空 + if not position: + return None + # 对象转字典 + data = position.to_dict() + # 返回结果 + return data +# 添加岗位 + +def add_position(request, data: PositionForm): + db = get_db_session() + # 唯一性校验 + if db.query(Position).filter(and_(Position.name == data.name, Position.is_delete == 0)).first(): + return R.failed("岗位名称不能重复") + # 表单数据赋值给对象 + position = Position(**data.model_dump(exclude={'id'})) + position.create_user = get_user_id(request) + # 插入数据 + position.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新岗位 + +def update_position(request, data: PositionForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + # 唯一性校验(排除自身) + if db.query(Position).filter(and_(Position.name == data.name, Position.id != data.id, Position.is_delete == 0)).first(): + return R.failed("岗位名称不能重复") + + # 根据ID查询记录 + position = db.query(Position).filter(and_(Position.id == data.id, Position.is_delete == 0)).first() + # 查询结果判空 + if not position: + return R.failed("记录不存在") + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + result = db.query(Position).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除岗位 + +def delete_position(position_id): + return Position.batch_delete_r(position_id) +# 设置状态 + +def update_position_status(request, data: PositionStatusForm): + db = get_db_session() + # 根据ID查询记录 + position = db.query(Position).filter(and_(Position.id == data.id, Position.is_delete == 0)).first() + # 查询结果判空 + if not position: + return R.failed("记录不存在") + + # 更新记录 + result = db.query(Position).filter_by(id=data.id).update({ + "status": data.status, + "update_user": get_user_id(request), + "update_time": datetime.now(tz=timezone.utc) + }) + # 提交数据 + if not result: + return R.failed("设置失败") + # 返回结果 + return R.ok(msg="设置成功") +# 获取岗位数据列表 + +def get_position_list(): + db = get_db_session() + # 查询岗位数据列表 + position_rows = db.query(Position).filter(and_(Position.is_delete == 0, Position.status == 1)).order_by( + Position.sort.asc()).all() + # 实例化列表 + position_list = [] + # 遍历数据源 + if position_rows: + for v in position_rows: + # 对象转字典 + item = v.to_dict() + # 加入数组 + position_list.append(item) + # 返回结果 + return position_list diff --git a/apps/services/role.py b/apps/services/role.py new file mode 100644 index 0000000..67f356f --- /dev/null +++ b/apps/services/role.py @@ -0,0 +1,177 @@ +import asyncio +from datetime import datetime, timezone + +from fastapi.logger import logger +from sqlalchemy import and_ + +from apps.constants.message import PAGE_SIZE +from apps.forms.role import RoleForm, RoleStatusForm +from apps.models.role import Role +from extensions import get_db_session +from utils import R +from utils.security import get_user_id +from utils.request import like_contains, parse_id_list +from utils.perm_cache import invalidate_role_perms + + +# 查询角色分页数据 + +def get_role_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 分页查询 + query = db.query(Role).filter(Role.is_delete == 0) + # 角色名称 + name = request.query_params.get('name') + if name: + query = query.filter(Role.name.like(like_contains(name), escape='\\')) + # 排序 + query = query.order_by(Role.sort.asc()) + # 记录总数 + count = query.count() + # 查询分页数据 + role_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not role_list: + page = 1 + count = query.count() + role_list = query.limit(limit).offset(0).all() + # 实例化返回对象 + result = [] + # 遍历数据源 + if len(role_list) > 0: + for item in role_list: + # 对象转字典 + data = item.to_dict() + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据角色ID查询详情 + +def get_role_detail(role_id): + db = get_db_session() + # 根据ID查询角色 + role = db.query(Role).filter(and_(Role.id == role_id, Role.is_delete == 0)).first() + # 查询结果判空 + if not role: + return None + # 对象转字典 + data = role.to_dict() + # 返回结果 + return data +# 添加角色 + +def add_role(request, data: RoleForm): + db = get_db_session() + # 唯一性校验 + if db.query(Role).filter(and_(Role.name == data.name, Role.is_delete == 0)).first(): + return R.failed("角色名称不能重复") + # 表单数据赋值给对象 + role = Role(**data.model_dump(exclude={'id'})) + role.create_user = get_user_id(request) + # 插入数据 + role.save() + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新角色 + +def update_role(request, data: RoleForm): + db = get_db_session() + # 记录ID判空 + if not data.id or data.id <= 0: + return R.failed("记录ID不能为空") + # 唯一性校验(排除自身) + if db.query(Role).filter(and_(Role.name == data.name, Role.id != data.id, Role.is_delete == 0)).first(): + return R.failed("角色名称不能重复") + + # 根据ID查询记录 + role = db.query(Role).filter(and_(Role.id == data.id, Role.is_delete == 0)).first() + # 查询结果判空 + if not role: + return R.failed("记录不存在") + # 构造更新数据(排除 id) + updData = data.model_dump(exclude={'id'}) + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + result = db.query(Role).filter_by(id=data.id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 删除角色 + +async def delete_role(role_id): + def _delete(): + db = get_db_session() + # 记录ID为空判断 + if not role_id: + return R.failed("记录ID不存在"), None + # 分裂字符串 + id_list = parse_id_list(role_id) + if not id_list: + return R.failed("记录ID不存在"), None + # 批量查询并校验 + records = db.query(Role).filter(and_(Role.id.in_(id_list), Role.is_delete == 0)).all() + if len(records) != len(id_list): + return R.failed("记录不存在"), None + # 批量设置删除标识 + for record in records: + record.is_delete = 1 + return None, (id_list, records) + + # 同步DB操作放入线程池,避免阻塞事件循环 + err, result = await asyncio.to_thread(_delete) + if err: + return err + id_list, records = result + + # 清理被删角色下所有用户的权限缓存 + for rid in id_list: + await invalidate_role_perms(rid) + # 返回结果 + return R.ok(msg="本次共删除{0}条数据".format(len(records))) +# 设置状态 + +def update_role_status(request, data: RoleStatusForm): + db = get_db_session() + # 根据ID查询记录 + role = db.query(Role).filter(and_(Role.id == data.id, Role.is_delete == 0)).first() + # 查询结果判空 + if not role: + return R.failed("记录不存在") + + # 更新记录 + result = db.query(Role).filter_by(id=data.id).update({ + "status": data.status, + "update_user": get_user_id(request), + "update_time": datetime.now(tz=timezone.utc) + }) + # 提交数据 + if not result: + return R.failed("设置失败") + # 返回结果 + return R.ok(msg="设置成功") +# 获取角色列表 + +def get_role_list(): + db = get_db_session() + # 查询角色列表 + role_rows = db.query(Role).filter(and_(Role.is_delete == 0, Role.status == 1)).order_by(Role.sort.asc()).all() + # 实例化角色列表 + role_list = [] + # 遍历数据源 + if role_rows: + for v in role_rows: + # 对象转字典 + item = v.to_dict() + # 加入数组 + role_list.append(item) + # 返回结果 + return role_list diff --git a/apps/services/role_menu.py b/apps/services/role_menu.py new file mode 100644 index 0000000..f190220 --- /dev/null +++ b/apps/services/role_menu.py @@ -0,0 +1,80 @@ +import asyncio + +from fastapi.logger import logger +from sqlalchemy import and_ + +from apps.forms.role_menu import RoleMenuForm +from apps.models.menu import Menu +from apps.models.role_menu import RoleMenu +from extensions import get_db_session +from utils import R +from utils.perm_cache import invalidate_role_perms + + +# 根据角色ID查询菜单列表 + +def get_role_menu_list(role_id): + db = get_db_session() + # 获取全部菜单列表(仅查询 id、title、parent_id,避免 SELECT * 全量拉取大字段) + menuList = db.query(Menu.id, Menu.title, Menu.parent_id).filter(and_(Menu.is_delete == 0, Menu.status == 1)).order_by(Menu.sort.asc()).all() + if len(menuList) == 0: + return None + # 根据角色ID查询角色菜单关系数据 + role_menu = db.query(RoleMenu).filter(RoleMenu.role_id == role_id).all() + # 菜单ID集合 + idList = [] + # 遍历角色菜单数据源 + if role_menu: + for v in role_menu: + # 加入数组 + idList.append(v.menu_id) + + # 实例化菜单列表 + menu_list = [] + # 遍历菜单数据 + for menu in menuList: + # 菜单ID + menu_id = menu.id + data = { + 'id': menu_id, + 'title': menu.title, + 'open': True, + 'parentId': menu.parent_id, + } + if menu_id in idList: + data['checked'] = True + # 加入数组 + menu_list.append(data) + # 返回结果 + return menu_list +# 保存角色菜单数据 + +async def save_role_menu(request, data: RoleMenuForm): + def _save(): + db = get_db_session() + # 角色ID + role_id = data.role_id + # 菜单ID列表 + menuIdList = data.menu_id or [] + + # 删除当前角色ID相关菜单权限 + db.query(RoleMenu).filter(RoleMenu.role_id == role_id).delete() + + # 处理菜单数据 + if menuIdList: + new_menus = [] + for menu_id in menuIdList: + if not menu_id: + continue + new_menus.append(RoleMenu(role_id=role_id, menu_id=menu_id)) + db.add_all(new_menus) + return role_id + + # 同步DB操作放入线程池,避免阻塞事件循环 + role_id = await asyncio.to_thread(_save) + + # 清理该角色下所有用户的权限缓存 + await invalidate_role_perms(role_id) + + # 返回结果 + return R.ok() \ No newline at end of file diff --git a/apps/services/scheduler.py b/apps/services/scheduler.py new file mode 100644 index 0000000..1ebdcd0 --- /dev/null +++ b/apps/services/scheduler.py @@ -0,0 +1,368 @@ +""" +定时任务调度核心模块 + +使用 APScheduler 管理和执行定时任务。 +""" + +import json +import logging +import threading +from datetime import datetime +from typing import Dict, Optional + +import requests +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.jobstores.base import JobLookupError + +from config import FASTAPI_HOST, FASTAPI_PORT + +logger = logging.getLogger(__name__) + +# 调度器实例 +_scheduler: Optional[BackgroundScheduler] = None + + +def get_scheduler() -> BackgroundScheduler: + """获取调度器单例""" + global _scheduler + if _scheduler is None: + _scheduler = BackgroundScheduler(timezone='Asia/Shanghai') + return _scheduler + + +def build_full_url(url: str) -> str: + """构建完整URL,将相对路径拼接为带域名的URL""" + if not url: + return url + if url.startswith(('http://', 'https://')): + return url + if url.startswith('/'): + url = f"http://{FASTAPI_HOST}:{FASTAPI_PORT}{url}" + return url + + +# ============================================================ +# 任务执行核心函数 +# ============================================================ + +def execute_job( + job_id: int, + url: Optional[str] = None, + is_sync: int = 0, + job_trigger: Optional[str] = None, + params: Optional[Dict] = None +) -> Dict: + """执行任务(调度器调用入口),执行完成后写入任务日志""" + start_time = datetime.now() + result = {'success': False, 'error': '未知错误'} + try: + logger.info(f"执行定时任务 [ID: {job_id}, URL: {url}, Trigger: {job_trigger}]") + + if url: + result = _execute_by_url(job_id, url, is_sync, params) + elif job_trigger: + result = _execute_by_trigger(job_trigger, job_id, params) + else: + result = {'success': False, 'error': '任务既没有URL也没有触发器'} + + return result + + except Exception as e: + logger.error(f"执行任务异常 [ID: {job_id}]: {str(e)}") + result = {'success': False, 'error': str(e)} + return result + + finally: + _record_execution_log(job_id, result, start_time) + + +def _record_execution_log(job_id: int, result: Dict, start_time: datetime): + """记录一次任务执行日志""" + try: + from apps.services.job_log import record_job_log + + end_time = datetime.now() + success = bool(result.get('success')) + status = 0 if success else 1 + if not success: + job_message = '任务执行失败' + exception_info = str(result.get('error')) + elif result.get('status') == 'triggered': + job_message = '异步任务已触发' + exception_info = None + else: + job_message = '任务执行成功' + exception_info = None + + record_job_log(job_id, start_time, end_time, status, + exception_info=exception_info, job_message=job_message) + except Exception as e: + logger.error(f"记录任务日志失败 [ID: {job_id}]: {str(e)}") + + +def _execute_by_url(job_id: int, url: str, is_sync: int, params: Optional[Dict] = None) -> Dict: + """通过URL方式执行任务""" + full_url = build_full_url(url) + request_data = { + 'job_id': job_id, + 'timestamp': datetime.now().isoformat(), + 'params': params or {} + } + + if is_sync == 1: + return _send_sync_request(full_url, request_data, job_id) + else: + return _send_async_request(full_url, request_data, job_id) + + +def _send_sync_request(url: str, data: Dict, job_id: int) -> Dict: + """发送同步HTTP请求""" + try: + logger.info(f"发送同步请求 [ID: {job_id}, URL: {url}]") + headers = {'Content-Type': 'application/json', 'User-Agent': 'Scheduler/1.0'} + response = requests.post(url, json=data, timeout=30, headers=headers) + response.raise_for_status() + + try: + result_data = response.json() + except Exception: + result_data = {'text': response.text[:200]} + + return {'success': True, 'status_code': response.status_code, 'data': result_data} + + except requests.exceptions.RequestException as e: + return {'success': False, 'error': str(e)} + + +def _send_async_request(url: str, data: Dict, job_id: int) -> Dict: + """发送异步HTTP请求(独立线程)""" + + def async_request(): + try: + headers = {'Content-Type': 'application/json'} + resp = requests.post(url, json=data, timeout=30, headers=headers) + logger.info(f"异步任务完成 [ID: {job_id}, 状态码: {resp.status_code}]") + except Exception as e: + logger.error(f"异步任务异常 [ID: {job_id}]: {str(e)}") + + thread = threading.Thread(target=async_request) + thread.start() + return {'success': True, 'status': 'triggered'} + + +def _execute_by_trigger(trigger: str, job_id: int, params: Optional[Dict] = None) -> Dict: + """通过触发器方式执行任务""" + try: + from apps.services.job_executor import job_executor + # 剔除 job_id,避免与 execute(job_id=...) 关键字参数冲突 + trigger_params = dict(params or {}) + trigger_params.pop('job_id', None) + result = job_executor.execute(trigger, job_id=job_id, **trigger_params) + # 处理器返回 success=False 时透传失败,而非一律视为成功 + if isinstance(result, dict) and result.get('success') is False: + error = result.get('message') or result.get('error') or '任务执行失败' + return {'success': False, 'trigger': trigger, 'error': error} + return {'success': True, 'trigger': trigger, 'data': result} + except Exception as e: + return {'success': False, 'error': str(e)} + + +# ============================================================ +# 调度器任务管理函数 +# ============================================================ + +def _parse_job_params(params: Optional[str]) -> Dict: + """解析任务参数(JSON字符串 -> dict),容错非法输入""" + if not params: + return {} + try: + parsed = json.loads(params) + if isinstance(parsed, dict): + return parsed + except Exception: + logger.warning(f"任务参数不是合法JSON,已忽略: {str(params)[:200]}") + return {} + + +def _build_misfire_config(execute_policy) -> Dict: + """根据执行策略映射 APScheduler 的 misfire/coalesce 参数""" + if execute_policy == 3: + # 放弃执行:错过的运行不补 + return {'misfire_grace_time': 0, 'coalesce': True} + if execute_policy == 2: + # 执行一次:短时间内错过的补跑一次 + return {'misfire_grace_time': 600, 'coalesce': True} + # 立即执行(默认):错过立即补跑一次 + return {'misfire_grace_time': 3600, 'coalesce': True} + + +def add_job_to_scheduler(job_dict: dict) -> bool: + """添加任务到调度器""" + try: + job_id = job_dict.get('id') + job_status = job_dict.get('status') + job_cron = job_dict.get('cron_expression') + job_url = job_dict.get('url') + job_sync = job_dict.get('is_sync', 0) + job_trigger = job_dict.get('job_trigger') + job_name = job_dict.get('job_name', '') + job_params = _parse_job_params(job_dict.get('params')) + + if job_status != 1: + return False + + if not job_cron: + logger.warning(f"跳过添加任务: Cron表达式为空 (id={job_id})") + return False + + # 验证Cron表达式 + try: + trigger = CronTrigger.from_crontab(job_cron) + next_run = trigger.get_next_fire_time(None, datetime.now()) + if next_run is None: + logger.warning(f"Cron表达式不会触发 (id={job_id})") + return False + except Exception as e: + logger.error(f"Cron表达式无效 (id={job_id}): {str(e)}") + return False + + scheduler = get_scheduler() + scheduler_job_id = f"job_{job_id}" + + # 移除已存在的同名任务 + try: + scheduler.remove_job(scheduler_job_id) + except JobLookupError: + pass + + misfire_config = _build_misfire_config(job_dict.get('execute_policy', 1)) + + scheduler.add_job( + func=execute_job, + trigger=trigger, + args=[job_id, job_url, job_sync, job_trigger, job_params], + id=scheduler_job_id, + name=job_name, + replace_existing=True, + **misfire_config + ) + + logger.info(f"任务已添加到调度器 [ID: {job_id}, cron: {job_cron}]") + return True + + except Exception as e: + logger.error(f"添加任务到调度器异常: {str(e)}") + return False + + +def remove_job_from_scheduler(job_id: int) -> bool: + """从调度器移除任务""" + try: + get_scheduler().remove_job(f"job_{job_id}") + return True + except JobLookupError: + return True + except Exception as e: + logger.error(f"移除任务异常: {str(e)}") + return False + + +def pause_job_in_scheduler(job_id: int) -> bool: + """暂停调度器中的任务""" + try: + get_scheduler().pause_job(f"job_{job_id}") + return True + except JobLookupError: + return True + except Exception as e: + logger.error(f"暂停任务异常: {str(e)}") + return False + + +def resume_job_in_scheduler(job_id: int) -> bool: + """恢复调度器中的任务""" + try: + get_scheduler().resume_job(f"job_{job_id}") + return True + except JobLookupError: + return True + except Exception as e: + logger.error(f"恢复任务异常: {str(e)}") + return False + + +def run_job_once(job_id: int, params: Optional[Dict] = None) -> Dict: + """立即执行一次任务(不依赖调度器)""" + try: + from apps.models.job import Job + from extensions import get_db_session + + db = get_db_session() + job = db.query(Job).filter(Job.id == job_id, Job.is_delete == 0).first() + if not job: + return {'success': False, 'error': '任务不存在'} + + return execute_job(job.id, job.url, job.is_sync, job.job_trigger, params or {}) + + except Exception as e: + return {'success': False, 'error': str(e)} + + +# ============================================================ +# 调度器初始化 +# ============================================================ + +def init_scheduler() -> int: + """初始化调度器,启动并从数据库加载所有启用的任务""" + try: + from apps.models.job import Job + from extensions.sqlalchemy import SessionLocal + + scheduler = get_scheduler() + if not scheduler.running: + scheduler.start() + logger.info("调度器已启动") + + # lifespan 启动阶段无请求上下文,需用独立会话而非请求级 get_db_session() + session = SessionLocal() + try: + jobs = session.query(Job).filter(Job.status == 1, Job.is_delete == 0).all() + # 转为字典避免 Session 分离问题 + job_dicts = [job.to_dict() for job in jobs] + finally: + session.close() + + success_count = 0 + for job_dict in job_dicts: + if add_job_to_scheduler(job_dict): + success_count += 1 + + logger.info(f"调度器初始化完成,加载 {success_count}/{len(job_dicts)} 个任务") + return success_count + + except Exception as e: + logger.error(f"初始化调度器异常: {str(e)}") + return 0 + + +def shutdown_scheduler(): + """关闭调度器""" + try: + scheduler = get_scheduler() + if scheduler.running: + scheduler.shutdown(wait=False) + logger.info("调度器已关闭") + except Exception as e: + logger.error(f"关闭调度器异常: {str(e)}") + + +def reload_scheduler() -> int: + """重新加载调度器:清空所有任务,重新从数据库加载""" + try: + scheduler = get_scheduler() + scheduler.remove_all_jobs() + return init_scheduler() + except Exception as e: + logger.error(f"重新加载调度器异常: {str(e)}") + return 0 diff --git a/apps/services/upload.py b/apps/services/upload.py new file mode 100644 index 0000000..27313c2 --- /dev/null +++ b/apps/services/upload.py @@ -0,0 +1,252 @@ +import asyncio +import io +import logging +import mimetypes +import os +import random +import re +import time + +from config import FASTAPI_TEMP_PATH, FASTAPI_IMAGE_URL, FASTAPI_UPLOAD_DIR +from config.app import UPLOAD_ALLOWED_EXTS, UPLOAD_MAX_SIZE_MB +from utils import R +from utils.file import mkdir, verify_file_magic + +logger = logging.getLogger(__name__) + + +def _parse_allowed_extensions(exts): + """解析允许的扩展名参数,统一转为带点的列表格式 + + 支持列表或字符串输入,字符串支持逗号/竖线分隔 + """ + if not exts: + return [] + + if isinstance(exts, list): + return [ext if ext.startswith('.') else f'.{ext}' for ext in exts] + + if isinstance(exts, str): + if '|' in exts: + parts = exts.split('|') + elif ',' in exts: + parts = exts.split(',') + else: + parts = [exts] + + result = [] + for part in parts: + part = part.strip().lower() + if part: + if not part.startswith('.'): + part = f'.{part}' + result.append(part) + return result + + return [] + + +def _is_safe_filename(filename: str) -> bool: + """校验文件名安全性,防止路径遍历攻击 + + 拒绝包含路径分隔符或 .. 的文件名,确保不会跳出目标目录。 + """ + if not filename: + return False + # 规范化后检测路径遍历 + normalized = os.path.normpath(filename) + if '..' in normalized.split(os.sep): + return False + # 文件名不应包含路径分隔符 + basename = os.path.basename(normalized) + return basename == normalized or basename == filename + + +def _guess_mime_type(ext_with_dot): + """根据扩展名推断MIME类型""" + mime_map = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', + '.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp', + '.svg': 'image/svg+xml', '.ico': 'image/x-icon', + '.txt': 'text/plain', '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', + '.zip': 'application/zip', '.rar': 'application/x-rar-compressed', + '.7z': 'application/x-7z-compressed', + } + return mime_map.get(ext_with_dot, 'application/octet-stream') + + +def _get_mime_type(filename, ext_with_dot): + """获取文件的MIME类型""" + mime_type, _ = mimetypes.guess_type(filename) + if not mime_type: + mime_type = _guess_mime_type(ext_with_dot) + return mime_type + + +def _generate_filename(ext_with_dot): + """生成唯一文件名:年月日时分秒 + 5位随机数 + 扩展名""" + timestamp = time.strftime('%Y%m%d%H%M%S') + random_num = '%05d' % random.randint(0, 99999) + return f"{timestamp}{random_num}{ext_with_dot}" + + +# SVG中需要移除的危险标签和属性 +_SVG_DANGEROUS_TAGS = re.compile( + r'<\s*/?(script|iframe|object|embed|applet|form|input|button|textarea|select|link|meta|base)' + r'\b[^>]*>', + re.IGNORECASE | re.DOTALL, +) + +_SVG_DANGEROUS_ATTRS = re.compile( + r'\s+' + r'(on\w+' # on* 事件属性:onclick, onload, onerror 等 + r'|href\s*=\s*["\']?\s*javascript' # javascript: 链接 + r'|xlink:href\s*=\s*["\']?\s*javascript)' + r'[^>]*>', + re.IGNORECASE, +) + + +def _sanitize_svg(content: bytes) -> bytes: + """清洗SVG内容,移除可能导致XSS攻击的危险标签和属性""" + try: + text = content.decode('utf-8') + except UnicodeDecodeError: + return content + + # 移除危险标签 + text = _SVG_DANGEROUS_TAGS.sub('', text) + # 移除危险属性 + text = _SVG_DANGEROUS_ATTRS.sub('>', text) + return text.encode('utf-8') + + +def _save_upload(content, original_name, file_ext_with_dot): + """同步校验文件内容并落盘,返回 R 结果(在线程池中执行)""" + file_size = len(content) + + # 校验文件大小 + max_size_bytes = UPLOAD_MAX_SIZE_MB * 1024 * 1024 + if file_size > max_size_bytes: + return R.failed(msg=f"文件大小超过限制,最大允许 {UPLOAD_MAX_SIZE_MB}MB") + + # 校验魔术字节 + is_valid, magic_msg = verify_file_magic(io.BytesIO(content), file_ext_with_dot) + if not is_valid: + return R.failed(msg=magic_msg) + + # 生成文件名和路径 + generated_name = _generate_filename(file_ext_with_dot) + date_dir = time.strftime('%Y%m%d') + save_dir = os.path.join(FASTAPI_TEMP_PATH, date_dir) + mkdir(save_dir) + file_path = os.path.join(save_dir, generated_name) + + # 保存文件 + if file_ext_with_dot == '.svg': + content = _sanitize_svg(content) + with open(file_path, 'wb') as f: + f.write(content) + + # 生成访问信息 + relative_path = file_path.replace(FASTAPI_UPLOAD_DIR, "").replace("\\", "/") + file_url = FASTAPI_IMAGE_URL + relative_path + file_ext = file_ext_with_dot[1:] if file_ext_with_dot.startswith('.') else file_ext_with_dot + mime_type = _get_mime_type(original_name, file_ext_with_dot) + + result = { + 'originalName': original_name, + 'fileExtension': file_ext, + 'fileType': mime_type, + 'fileSize': file_size, + 'fileName': generated_name, + 'filePath': relative_path, + 'fileUrl': file_url, + } + + return R.ok(msg="上传成功", data=result) + + +async def upload_image(file): + """上传图片 + + Args: + file: FastAPI UploadFile 对象 + + Returns: + R: 上传结果,包含文件信息 + """ + try: + if not file or not file.filename: + return R.failed(msg="请选择要上传的文件") + + # 获取文件基本信息 + original_name = file.filename + + # 校验文件名安全性,防止路径遍历 + if not _is_safe_filename(original_name): + return R.failed(msg="文件名不合法") + + file_ext_with_dot = os.path.splitext(original_name)[1].lower() + + # 验证图片扩展名 + allowed_image_exts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico'] + if file_ext_with_dot not in allowed_image_exts: + return R.failed(msg="文件格式不支持,仅支持图片格式") + + # 读取文件内容 + content = await file.read() + + # 同步校验与落盘放入线程池,避免阻塞事件循环 + return await asyncio.to_thread(_save_upload, content, original_name, file_ext_with_dot) + + except Exception as e: + logger.error(f"图片上传异常: {str(e)}") + return R.failed(msg=f"上传失败: {str(e)}") + + +async def upload_file(file, file_exts=None): + """上传文件(兼容图片和各类文件) + + Args: + file: FastAPI UploadFile 对象 + file_exts: 允许的文件扩展名,默认使用 UPLOAD_ALLOWED_EXTS 全局配置 + + Returns: + R: 上传结果,包含文件信息 + """ + try: + if not file or not file.filename: + return R.failed(msg="请选择要上传的文件") + + # 获取文件基本信息 + original_name = file.filename + + # 校验文件名安全性,防止路径遍历 + if not _is_safe_filename(original_name): + return R.failed(msg="文件名不合法") + + file_ext_with_dot = os.path.splitext(original_name)[1].lower() + file_ext = file_ext_with_dot[1:] if file_ext_with_dot.startswith('.') else '' + + # 验证文件扩展名 + allowed_exts = _parse_allowed_extensions(file_exts or UPLOAD_ALLOWED_EXTS) + if allowed_exts and file_ext_with_dot not in allowed_exts: + return R.failed(msg=f"文件格式不支持,仅支持 {', '.join(allowed_exts)} 格式") + + # 读取文件内容 + content = await file.read() + + # 同步校验与落盘放入线程池,避免阻塞事件循环 + return await asyncio.to_thread(_save_upload, content, original_name, file_ext_with_dot) + + except Exception as e: + logger.error(f"文件上传异常: {str(e)}") + return R.failed(msg=f"上传失败: {str(e)}") diff --git a/apps/services/user.py b/apps/services/user.py new file mode 100644 index 0000000..9cbe3cf --- /dev/null +++ b/apps/services/user.py @@ -0,0 +1,381 @@ +from datetime import datetime, timezone +import asyncio + +from fastapi.logger import logger +from sqlalchemy import and_ + +from apps.constants.constants import GENDER_LIST +from apps.constants.message import PAGE_SIZE, DEFAULT_PASSWORD +from apps.forms.user import UserForm, UserStatusForm, UpdatePwdForm, UserInfoForm, ResetPwdForm +from apps.models.dept import Dept +from apps.models.level import Level +from apps.models.position import Position +from apps.models.user import User +from apps.models.user_role import UserRole +from apps.services import user_role +from extensions import get_db_session +from utils import R +from utils import password +from utils.rich_text import get_file_url, save_file +from utils.request import like_contains +from utils.security import get_user_id +from utils.perm_cache import invalidate_user_perms + + +# 查询用户分页数据 + +def get_user_page(request): + db = get_db_session() + # 页码 + page = int(request.query_params.get("pageNo", 1)) + # 每页数 + limit = int(request.query_params.get("pageSize", PAGE_SIZE)) + # 实例化查询对象 + query = db.query(User).filter(User.is_delete == 0) + # 用户账号 + username = request.query_params.get('username') + if username: + query = query.filter(User.username.like(like_contains(username), escape='\\')) + # 性别 + gender = request.query_params.get('gender') + if gender: + query = query.filter(User.gender == gender) + # 用户状态 + status = request.query_params.get('status') + if status: + query = query.filter(User.status == status) + # 排序 + query = query.order_by(User.id.asc()) + # 记录总数 + count = query.count() + # 分页查询 + user_list = query.limit(limit).offset((page - 1) * limit).all() + # 若当前页超出末页(数据被并发删除),自动回退到第一页 + if page > 1 and not user_list: + page = 1 + count = query.count() + user_list = query.limit(limit).offset(0).all() + # 实例化结果 + result = [] + # 遍历数据源 + if len(user_list) > 0: + # 仅查询当前页用到的部门ID + dept_ids = {item.dept_id for item in user_list if item.dept_id} + deptList = {} + if dept_ids: + for dept in db.query(Dept).filter(Dept.id.in_(dept_ids), Dept.is_delete == 0).all(): + deptList[dept.id] = dept.name + + # 仅查询当前页用到的职级ID + level_ids = {item.level_id for item in user_list if item.level_id} + levelList = {} + if level_ids: + for level in db.query(Level).filter(Level.id.in_(level_ids), Level.is_delete == 0).all(): + levelList[level.id] = level.name + + # 仅查询当前页用到的岗位ID + position_ids = {item.position_id for item in user_list if item.position_id} + positionList = {} + if position_ids: + for position in db.query(Position).filter(Position.id.in_(position_ids), Position.is_delete == 0).all(): + positionList[position.id] = position.name + + # 批量查询用户角色 + user_ids = [item.id for item in user_list] + roleMap = user_role.get_user_role_map(user_ids) + + for item in user_list: + # 城市 + city_list = [] + # 省份编码 + city_list.append(item.province_code) + # 城市编码 + city_list.append(item.city_code) + # 县区编码 + city_list.append(item.district_code) + + # 对象转字典 + data = item.to_dict() + # 性别 + data['gender_name'] = GENDER_LIST.get(item.gender) + # 头像 + data['avatar'] = get_file_url(item.avatar) if item.avatar else "" + # 部门名称 + data['dept_name'] = deptList.get(item.dept_id) if deptList else None + # 职级名称 + data['level_name'] = levelList.get(item.level_id) if levelList else None + # 岗位名称 + data['position_name'] = positionList.get(item.position_id) if positionList else None + # 角色列表 + data['roleList'] = roleMap.get(item.id, []) + # 行政区划 + data['city'] = city_list + # 加入列表 + result.append(data) + # 返回结果 + return R.ok(data=result, count=count) +# 根据ID查询用户详情 + +def get_user_detail(user_id): + db = get_db_session() + # 根据ID查询用户 + user = db.query(User).filter(and_(User.id == user_id, User.is_delete == 0)).first() + # 查询结果判空 + if not user: + return None + + # 获取用户角色数据 + roleList = db.query(UserRole).filter(UserRole.user_id == user.id).all() + roles = [] + for role in roleList: + roles.append(role.role_id) + + # 城市编码 + cityList = [] + # 省份编号 + cityList.append(user.province_code) + # 城市编码 + cityList.append(user.city_code) + # 县区编码 + cityList.append(user.district_code) + + # 对象转字典 + data = user.to_dict() + # 头像 + data['avatar'] = get_file_url(user.avatar) if user.avatar else "" + # 角色 + data['roles'] = roles + # 行政区划 + data['city'] = cityList + + # 返回结果 + return data +# 添加用户 + +async def add_user(request, data: UserForm): + def _add(): + db = get_db_session() + # 唯一性校验 + if db.query(User).filter(and_(User.username == data.username, User.is_delete == 0)).first(): + return R.failed("登录账号不能重复"), None + # 获取表单数据(排除非数据列) + formData = data.model_dump(exclude={'city', 'roles'}) + + # 图片处理 + avatar = data.avatar + if avatar: + formData['avatar'] = save_file(avatar, "user") + + # 密码加密:未传密码时使用默认密码 + pwd = data.password or DEFAULT_PASSWORD + salt = password.generate_salt() + formData['password'] = password.encrypt_password(pwd, salt) + formData['salt'] = salt + + # 行政区划处理 + citys = data.city + if citys and len(citys) == 3: + formData['province_code'] = citys[0] + formData['city_code'] = citys[1] + formData['district_code'] = citys[2] + + # 表单数据赋值给对象 + user = User(**formData) + user.create_user = get_user_id(request) + # 插入数据 + user.save() + return None, user.id + + # 同步DB/文件操作放入线程池,避免阻塞事件循环 + err, user_id = await asyncio.to_thread(_add) + if err: + return err + + # 创建用户角色数据 + 清理权限缓存 + await add_user_role(user_id, data.roles) + + # 返回结果 + return R.ok(msg="添加成功") + + +# 更新用户 + +async def update_user(request, data: UserForm): + def _update(): + db = get_db_session() + # 记录ID判空 + user_id = data.id + if not user_id or int(user_id) <= 0: + return R.failed("记录ID不能为空"), None + # 唯一性校验(排除自身) + if db.query(User).filter(and_(User.username == data.username, User.id != data.id, User.is_delete == 0)).first(): + return R.failed("登录账号不能重复"), None + + # 根据ID查询记录 + user = db.query(User).filter(and_(User.id == user_id, User.is_delete == 0)).first() + # 查询结果判空 + if not user: + return R.failed("记录不存在"), None + + # 获取表单数据(排除敏感字段和非数据列) + formData = data.model_dump(exclude={'id', 'password', 'city', 'roles'}) + + # 图片处理 + avatar = data.avatar + if avatar: + formData['avatar'] = save_file(avatar, "user") + + # 密码加密:仅传入新密码时才更新,否则保留原值 + pwd = data.password + if pwd: + salt = password.generate_salt() + formData['password'] = password.encrypt_password(pwd, salt) + formData['salt'] = salt + + # 行政区划处理 + citys = data.city + if citys and len(citys) == 3: + formData['province_code'] = citys[0] + formData['city_code'] = citys[1] + formData['district_code'] = citys[2] + + updData = formData + updData['update_user'] = get_user_id(request) + updData['update_time'] = datetime.now(tz=timezone.utc) + # 更新数据 + result = db.query(User).filter_by(id=user_id).update(updData) + if not result: + return R.failed("更新失败"), None + return None, user_id + + # 同步DB/文件操作放入线程池,避免阻塞事件循环 + err, user_id = await asyncio.to_thread(_update) + if err: + return err + + # 创建用户角色数据 + 清理权限缓存 + await add_user_role(user_id, data.roles) + + # 返回结果 + return R.ok(msg="更新成功") +# 创建用户角色信息 + +async def add_user_role(user_id, roleIdList): + def _save(): + db = get_db_session() + # 删除用户角色关系数据 + db.query(UserRole).filter(UserRole.user_id == user_id).delete() + # 创建新的用户角色关系 + if roleIdList: + new_roles = [] + for roleId in roleIdList: + if not roleId: + continue + new_roles.append(UserRole(user_id=user_id, role_id=roleId)) + db.add_all(new_roles) + + # 同步DB操作放入线程池,避免阻塞事件循环 + await asyncio.to_thread(_save) + # 清理该用户权限缓存 + await invalidate_user_perms(user_id) +# 删除用户 + +def delete_user(user_id): + return User.batch_delete_r(user_id) +# 设置状态 + +def update_user_status(request, data: UserStatusForm): + db = get_db_session() + # 记录ID + id = data.id + # 状态值 + status = data.status + # 根据ID查询记录 + user = db.query(User).filter(and_(User.id == id, User.is_delete == 0)).first() + # 查询结果判空 + if not user: + return R.failed("记录不存在") + + # 更新记录 + result = db.query(User).filter_by(id=id).update({ + "status": status, + "update_user": get_user_id(request), + "update_time": datetime.now(tz=timezone.utc) + }) + # 提交数据 + if not result: + return R.failed("设置失败") + # 返回结果 + return R.ok(msg="设置成功") +# 更新用户信息 + +def update_user_info(request, data: UserInfoForm): + db = get_db_session() + # 用户ID + user_id = get_user_id(request) + # 根据ID查询记录 + user = db.query(User).filter(and_(User.id == user_id, User.is_delete == 0)).first() + # 查询结果判空 + if not user: + return R.failed("记录不存在") + + # 更新记录 + updData = data.model_dump() + result = db.query(User).filter_by(id=user_id).update(updData) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 更新密码 + +def update_pwd(request, data: UpdatePwdForm): + db = get_db_session() + # 用户ID + user_id = get_user_id(request) + + # 根据ID查询用户 + user = db.query(User).filter(and_(User.is_delete == 0, User.id == user_id)).first() + if not user: + return R.failed("用户不存在") + + # 验证原始密码 + if not password.verify_password(data.oldPassword, user.salt or '', user.password or ''): + return R.failed("原始密码不正确") + + # 加密新密码 + salt = password.generate_salt() + new_hash = password.encrypt_password(data.newPassword, salt) + result = db.query(User).filter_by(id=user_id).update({ + "password": new_hash, + "salt": salt + }) + # 提交数据 + if not result: + return R.failed("更新失败") + # 返回结果 + return R.ok(msg="更新成功") +# 重置密码 + +def reset_user_pwd(request, data: ResetPwdForm): + db = get_db_session() + # 用户ID + user_id = data.id + + # 根据ID查询用户 + user = db.query(User).filter(and_(User.is_delete == 0, User.id == user_id)).first() + if not user: + return R.failed("用户不存在") + + # 加密新密码 + salt = password.generate_salt() + new_hash = password.encrypt_password(DEFAULT_PASSWORD, salt) + result = db.query(User).filter_by(id=user_id).update({ + "password": new_hash, + "salt": salt + }) + # 提交数据 + if not result: + return R.failed("重置密码失败") + # 返回结果 + return R.ok(msg="重置密码成功") diff --git a/apps/services/user_role.py b/apps/services/user_role.py new file mode 100644 index 0000000..a4d4911 --- /dev/null +++ b/apps/services/user_role.py @@ -0,0 +1,48 @@ +from apps.models.user_role import UserRole +from apps.models.role import Role +from extensions import get_db_session + + +# 根据用户ID查询角色列表 + +def get_user_role_list(userId): + db = get_db_session() + rows = ( + db.query(Role.id, Role.name) + .join(UserRole, UserRole.role_id == Role.id) + .filter(UserRole.user_id == userId, Role.is_delete == 0) + .all() + ) + role_list = [] + if rows: + for v in rows: + item = { + 'id': v.id, + 'name': v.name, + } + role_list.append(item) + return role_list + + +# 根据用户ID列表批量查询角色,返回 {user_id: [{id, name}, ...]} 字典 + +def get_user_role_map(user_ids): + if not user_ids: + return {} + db = get_db_session() + # 用ORM查询替代原生SQL,避免tuple单元素/空列表的IN语法问题 + from apps.models.user_role import UserRole + from apps.models.role import Role + rows = ( + db.query(UserRole.user_id, Role.id.label('role_id'), Role.name.label('role_name')) + .join(Role, Role.id == UserRole.role_id) + .filter(UserRole.user_id.in_(user_ids), Role.is_delete == 0) + .all() + ) + role_map = {} + for row in rows: + uid = row.user_id + if uid not in role_map: + role_map[uid] = [] + role_map[uid].append({'id': row.role_id, 'name': row.role_name}) + return role_map diff --git a/apps/views/__init__.py b/apps/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/views/ad.py b/apps/views/ad.py new file mode 100644 index 0000000..6c2acda --- /dev/null +++ b/apps/views/ad.py @@ -0,0 +1,95 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.forms.ad import AdForm, AdStatusForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import ad + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询广告分页数据') +@permission_required("sys:ad:page") +def page(request: Request): + # 调用查询分页数据服务方法 + result = ad.get_ad_page(request) + # 返回结果 + return result + + +# 查询广告详情 +@router.get('/detail/{id}', summary='查询广告详情') +@permission_required("sys:ad:detail") +def detail(request: Request, id): + # 调用查询广告详情服务方法 + data = ad.get_ad_detail(id) + # 返回结果 + return R.ok(data=data) + + +# 添加广告 +@router.post('/add', summary='添加广告') +@permission_required("sys:ad:add") +@check_demo +def add(request: Request, data: AdForm): + # 调用添加广告服务 + result = ad.add_ad(request, data) + # 返回结果 + return result + + +# 更新广告 +@router.put('/update', summary='更新广告') +@permission_required("sys:ad:update") +@check_demo +def update(request: Request, data: AdForm): + # 调用更新广告服务方法 + result = ad.update_ad(request, data) + # 返回结果 + return result + + +# 删除广告 +@router.delete('/delete/{id}', summary='删除广告') +@permission_required("sys:ad:delete") +@check_demo +def delete(request: Request, id): + # 调用删除广告服务方法 + result = ad.delete_ad(id) + # 返回结果 + return result + + +# 设置广告状态 +@router.put('/status', summary='设置广告状态') +@permission_required("sys:ad:status") +@check_demo +def status(request: Request, data: AdStatusForm): + # 调用设置广告状态服务方法 + result = ad.update_ad_status(request, data) + # 返回结果 + return result + + +# 批量删除广告 +@router.delete('/batchDelete', summary='批量删除广告') +@permission_required("sys:ad:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除广告 + + Request Body: + [1, 2, 3] # 广告ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(ad.delete_ad, id_str) diff --git a/apps/views/ad_sort.py b/apps/views/ad_sort.py new file mode 100644 index 0000000..8c333c2 --- /dev/null +++ b/apps/views/ad_sort.py @@ -0,0 +1,93 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.forms.ad_sort import AdSortForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import ad_sort + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询广告位分页数据') +@permission_required("sys:adsort:page") +def page(request: Request): + # 调用查询分页数据服务方法 + result = ad_sort.get_ad_sort_page(request) + # 返回结果 + return result + + +# 查询广告位详情 +@router.get('/detail/{id}', summary='查询广告位详情') +@permission_required("sys:adsort:detail") +def detail(request: Request, id): + # 调用查询广告位详情服务方法 + data = ad_sort.get_ad_sort_detail(id) + # 返回结果 + return R.ok(data=data) + + +# 添加广告位 +@router.post('/add', summary='添加广告位') +@permission_required("sys:adsort:add") +@check_demo +def add(request: Request, data: AdSortForm): + # 调用添加广告位服务 + result = ad_sort.add_ad_sort(request, data) + # 返回结果 + return result + + +# 更新广告位 +@router.put('/update', summary='更新广告位') +@permission_required("sys:adsort:update") +@check_demo +def update(request: Request, data: AdSortForm): + # 调用更新广告位服务方法 + result = ad_sort.update_ad_sort(request, data) + # 返回结果 + return result + + +# 删除广告位 +@router.delete('/delete/{id}', summary='删除广告位') +@permission_required("sys:adsort:delete") +@check_demo +def delete(request: Request, id): + # 调用删除广告位服务方法 + result = ad_sort.delete_ad_sort(id) + # 返回结果 + return result + + +# 获取广告位列表 +@router.get('/getAdSortList', summary='获取广告位列表') +def get_ad_sort_list(): + # 调用广告位服务方法 + result = ad_sort.get_ad_sort_list() + # 返回结果 + return R.ok(result) + + +# 批量删除广告位 +@router.delete('/batchDelete', summary='批量删除广告位') +@permission_required("sys:adsort:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除广告位 + + Request Body: + [1, 2, 3] # 广告位ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(ad_sort.delete_ad_sort, id_str) diff --git a/apps/views/city.py b/apps/views/city.py new file mode 100644 index 0000000..03ae75a --- /dev/null +++ b/apps/views/city.py @@ -0,0 +1,134 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 一米阳光团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称"本产品")拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +import asyncio + +from fastapi import APIRouter, Request + +from apps.forms.city import CityForm +from apps.middleware.permission_required import permission_required +from apps.services import city +from apps.middleware.check_demo import check_demo + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:city:page") +def page(request: Request): + # 调用查询分页数据服务方法 + result = city.get_city_page(request) + # 返回结果 + return result + + +# 查询城市详情 +@router.get('/detail/{id}', summary='查询城市详情') +@permission_required("sys:city:detail") +def detail(request: Request, id): + # 调用查询城市详情服务方法 + data = city.get_city_detail(id) + # 返回结果 + return R.ok(data=data) + + +# 添加城市 +@router.post('/add', summary='添加城市') +@permission_required("sys:city:add") +@check_demo +def add(request: Request, data: CityForm): + # 调用添加城市服务 + result = city.add_city(request, data) + # 返回结果 + return result + + +# 更新城市 +@router.put('/update', summary='更新城市') +@permission_required("sys:city:update") +@check_demo +def update(request: Request, data: CityForm): + # 调用更新城市服务方法 + result = city.update_city(request, data) + # 返回结果 + return result + + +# 删除城市 +@router.delete('/delete/{id}', summary='删除城市') +@permission_required("sys:city:delete") +@check_demo +def delete(request: Request, id): + # 调用删除城市服务方法 + result = city.delete_city(id) + # 返回结果 + return result + + +# 根据城市编码查询子级城市 +@router.get('/getChildList/{city_code}', summary='根据城市编码查询子级城市') +def get_child_list(city_code): + # 调用查询城市详情方法 + data = city.get_child_list(city_code) + # 返回结果 + return R.ok(data) + + +# 批量删除城市 +@router.delete('/batchDelete', summary='批量删除城市') +@permission_required("sys:city:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除城市 + + Request Body: + [1, 2, 3] # 城市ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(city.delete_city, id_str) diff --git a/apps/views/config.py b/apps/views/config.py new file mode 100644 index 0000000..afff17b --- /dev/null +++ b/apps/views/config.py @@ -0,0 +1,103 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.forms.config import ConfigForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import config + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:config:page") +def page(request: Request): + # 调用查询分页数据服务方法 + result = config.get_config_page(request) + # 返回结果 + return result + + +# 查询配置详情 +@router.get('/detail/{id}', summary='查询配置详情') +@permission_required("sys:config:detail") +def detail(request: Request, id): + # 调用查询配置详情服务方法 + data = config.get_config_detail(id) + # 返回结果 + return R.ok(data=data) + + +# 添加配置 +@router.post('/add', summary='添加配置') +@permission_required("sys:config:add") +@check_demo +def add(request: Request, data: ConfigForm): + # 调用添加配置服务 + result = config.add_config(request, data) + # 返回结果 + return result + + +# 更新配置 +@router.put('/update', summary='更新配置') +@permission_required("sys:config:update") +@check_demo +def update(request: Request, data: ConfigForm): + # 调用更新配置服务方法 + result = config.update_config(request, data) + # 返回结果 + return result + + +# 删除配置 +@router.delete('/delete/{id}', summary='删除配置') +@permission_required("sys:config:delete") +@check_demo +def delete(request: Request, id): + # 调用删除配置服务方法 + result = config.delete_config(id) + # 返回结果 + return result + + +# 批量删除配置 +@router.delete('/batchDelete', summary='批量删除配置') +@permission_required("sys:config:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除配置 + + Request Body: + [1, 2, 3] # 配置ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(config.delete_config, id_str) + + +# 获取网站配置数据 +@router.get('/web', summary='获取网站配置数据') +@permission_required("sys:config:page") +def web(request: Request): + # 调用获取网站配置信息方法 + data = config.get_config_info(request) + return R.ok(data=data) + + +# 保存网站配置数据 +@router.put('/webSave', summary='保存网站配置数据') +@permission_required("sys:config:update") +@check_demo +async def web_save(request: Request): + # 调用保存网站配置信息方法 + result = await config.save_config_info(request) + return result diff --git a/apps/views/config_item.py b/apps/views/config_item.py new file mode 100644 index 0000000..7d2d52a --- /dev/null +++ b/apps/views/config_item.py @@ -0,0 +1,95 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.forms.config_item import ConfigItemForm, ConfigItemStatusForm +from apps.middleware.permission_required import permission_required +from apps.services import config_item + +from apps.middleware.check_demo import check_demo +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:configitem:page") +def page(request: Request): + # 调用查询分页数据服务方法 + result = config_item.get_config_item_page(request) + # 返回结果 + return result + + +# 查询配置项详情 +@router.get('/detail/{id}', summary='查询配置项详情') +@permission_required("sys:configitem:detail") +def detail(request: Request, id): + # 调用查询配置项详情服务方法 + data = config_item.get_config_item_detail(id) + # 返回结果 + return R.ok(data=data) + + +# 添加配置项 +@router.post('/add', summary='添加配置项') +@permission_required("sys:configitem:add") +@check_demo +def add(request: Request, data: ConfigItemForm): + # 调用添加配置项服务 + result = config_item.add_config_item(request, data) + # 返回结果 + return result + + +# 更新配置项 +@router.put('/update', summary='更新配置项') +@permission_required("sys:configitem:update") +@check_demo +def update(request: Request, data: ConfigItemForm): + # 调用更新配置项服务方法 + result = config_item.update_config_item(request, data) + # 返回结果 + return result + + +# 删除配置项 +@router.delete('/delete/{id}', summary='删除配置项') +@permission_required("sys:configitem:delete") +@check_demo +def delete(request: Request, id): + # 调用删除配置项服务方法 + result = config_item.delete_config_item(id) + # 返回结果 + return result + + +# 设置配置项状态 +@router.put('/status', summary='设置配置项状态') +@permission_required("sys:configitem:status") +@check_demo +def status(request: Request, data: ConfigItemStatusForm): + # 调用设置配置项状态服务方法 + result = config_item.update_config_item_status(request, data) + # 返回结果 + return result + + +# 批量删除配置项 +@router.delete('/batchDelete', summary='批量删除配置项') +@permission_required("sys:configitem:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除配置项 + + Request Body: + [1, 2, 3] # 配置项ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(config_item.delete_config_item, id_str) diff --git a/apps/views/dept.py b/apps/views/dept.py new file mode 100644 index 0000000..6910347 --- /dev/null +++ b/apps/views/dept.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter, Request + +from apps.forms.dept import DeptForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import dept + +from utils import R + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/list', summary='查询分页数据') +@permission_required("sys:dept:list") +def list(request: Request): + # 调用查询分页数据服务方法 + result = dept.get_dept_page(request) + # 返回结果 + return result + + +# 查询部门详情 +@router.get('/detail/{id}', summary='查询部门详情') +@permission_required("sys:dept:detail") +def detail(request: Request, id): + # 调用查询部门详情服务方法 + data = dept.get_dept_detail(id) + # 返回结果 + return R.ok(data=data) + + +# 添加部门 +@router.post('/add', summary='添加部门') +@permission_required("sys:dept:add") +@check_demo +def add(request: Request, data: DeptForm): + # 调用添加部门服务 + result = dept.add_dept(request, data) + # 返回结果 + return result + + +# 更新部门 +@router.put('/update', summary='更新部门') +@permission_required("sys:dept:update") +@check_demo +def update(request: Request, data: DeptForm): + # 调用更新部门服务方法 + result = dept.update_dept(request, data) + # 返回结果 + return result + + +# 删除部门 +@router.delete('/delete/{id}', summary='删除部门') +@permission_required("sys:dept:delete") +@check_demo +def delete(request: Request, id): + # 调用删除部门服务方法 + result = dept.delete_dept(id) + # 返回结果 + return result + + +# 获取部门列表 +@router.get('/getDeptList', summary='获取部门列表') +def get_dept_list(): + # 调用查询部门数据方法 + result = dept.get_dept_list() + # 返回结果 + return R.ok(data=result) diff --git a/apps/views/dict.py b/apps/views/dict.py new file mode 100644 index 0000000..bf4eb93 --- /dev/null +++ b/apps/views/dict.py @@ -0,0 +1,74 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.forms.dict import DictForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import dict as dict_service + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:dict:page") +def page(request: Request): + result = dict_service.get_dict_page(request) + return result + + +# 查询字典详情 +@router.get('/detail/{id}', summary='查询字典详情') +@permission_required("sys:dict:detail") +def detail(request: Request, id): + data = dict_service.get_dict_detail(id) + return R.ok(data=data) + + +# 添加字典 +@router.post('/add', summary='添加字典') +@permission_required("sys:dict:add") +@check_demo +def add(request: Request, data: DictForm): + result = dict_service.add_dict(request, data) + return result + + +# 更新字典 +@router.put('/update', summary='更新字典') +@permission_required("sys:dict:update") +@check_demo +def update(request: Request, data: DictForm): + result = dict_service.update_dict(request, data) + return result + + +# 删除字典 +@router.delete('/delete/{id}', summary='删除字典') +@permission_required("sys:dict:delete") +@check_demo +def delete(request: Request, id): + result = dict_service.delete_dict(id) + return result + + +# 批量删除字典 +@router.delete('/batchDelete', summary='批量删除字典') +@permission_required("sys:dict:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除字典 + + Request Body: + [1, 2, 3] # 字典ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(dict_service.delete_dict, id_str) diff --git a/apps/views/dict_item.py b/apps/views/dict_item.py new file mode 100644 index 0000000..0a7180b --- /dev/null +++ b/apps/views/dict_item.py @@ -0,0 +1,83 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.forms.dict_item import DictItemForm, DictItemStatusForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import dict_item + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:dictitem:page") +def page(request: Request): + result = dict_item.get_dict_item_page(request) + return result + + +# 查询字典项详情 +@router.get('/detail/{id}', summary='查询字典项详情') +@permission_required("sys:dictitem:detail") +def detail(request: Request, id): + data = dict_item.get_dict_item_detail(id) + return R.ok(data=data) + + +# 添加字典项 +@router.post('/add', summary='添加字典项') +@permission_required("sys:dictitem:add") +@check_demo +def add(request: Request, data: DictItemForm): + result = dict_item.add_dict_item(request, data) + return result + + +# 更新字典项 +@router.put('/update', summary='更新字典项') +@permission_required("sys:dictitem:update") +@check_demo +def update(request: Request, data: DictItemForm): + result = dict_item.update_dict_item(request, data) + return result + + +# 删除字典项 +@router.delete('/delete/{id}', summary='删除字典项') +@permission_required("sys:dictitem:delete") +@check_demo +def delete(request: Request, id): + result = dict_item.delete_dict_item(id) + return result + + +# 设置字典项状态 +@router.put('/status', summary='设置字典项状态') +@permission_required("sys:dictitem:status") +@check_demo +def status(request: Request, data: DictItemStatusForm): + result = dict_item.update_dict_item_status(request, data) + return result + + +# 批量删除字典项 +@router.delete('/batchDelete', summary='批量删除字典项') +@permission_required("sys:dictitem:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除字典项 + + Request Body: + [1, 2, 3] # 字典项ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(dict_item.delete_dict_item, id_str) diff --git a/apps/views/index.py b/apps/views/index.py new file mode 100644 index 0000000..69fbbbf --- /dev/null +++ b/apps/views/index.py @@ -0,0 +1,128 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 一米阳光团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称"本产品")拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +import asyncio + +from fastapi import APIRouter +from sqlalchemy import and_ +from starlette.requests import Request + +from apps.forms.user import UserInfoForm, UpdatePwdForm +from apps.middleware.check_demo import check_demo +from apps.models.user import User +from apps.services import menu, user, login_log +from extensions import get_db_session +from utils import R +from utils.rich_text import get_file_url +from utils.security import get_user_id, get_username +from utils.jwt import add_token_to_blacklist, get_access_token + +# 创建路由 +router = APIRouter() + + +# 获取菜单列表 +@router.get('/menu', summary='获取菜单列表') +async def get_menu_list(request: Request): + # 获取菜单列表 + menuList = await menu.get_permission_menu_list(get_user_id(request)) + # 返回结果 + return R.ok(data=menuList) + + +# 获取用户信息 +@router.get('/user', summary='获取用户信息') +async def get_user_info(request: Request): + # 用户ID + userId = get_user_id(request) + + # 查询用户信息(同步DB查询放入线程池) + def _query_user(): + db = get_db_session() + return db.query(User).filter(and_(User.id == userId, User.is_delete == 0)).first() + + user = await asyncio.to_thread(_query_user) + if not user: + return R.failed("用户不存在") + # 获取权限节点列表 + permissionList = await menu.get_permissions_list(userId) + # 返回用户信息 + info = { + 'realname': user.realname, + 'nickname': user.nickname, + 'avatar': get_file_url(user.avatar) if user.avatar else None, + 'gender': user.gender, + 'mobile': user.mobile, + 'email': user.email, + 'intro': user.intro, + 'address': user.address, + 'roles': [], + 'permissionList': permissionList, + } + # 返回结果 + return R.ok(data=info) + + +# 个人中心 +@router.put('/userInfo', summary='个人中心') +@check_demo +def user_info(request: Request, data: UserInfoForm): + result = user.update_user_info(request, data) + return result + + +# 修改密码 +@router.put('/updatePwd', summary='修改密码') +@check_demo +def update_pwd(request: Request, data: UpdatePwdForm): + result = user.update_pwd(request, data) + return result + + +# 退出登录 +@router.post('/logout', summary='退出登录') +async def logout(request: Request): + success, access_token, _ = get_access_token(request) + if success and access_token: + await add_token_to_blacklist(access_token) + username = get_username(request) + await asyncio.to_thread(login_log.record_login_log, request, username, 2, 0, 0, result="退出成功") + return R.ok(msg="注销成功") diff --git a/apps/views/item.py b/apps/views/item.py new file mode 100644 index 0000000..35b3587 --- /dev/null +++ b/apps/views/item.py @@ -0,0 +1,93 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.forms.item import ItemForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import item + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:item:page") +def page(request: Request): + # 调用查询分页数据服务方法 + result = item.get_item_page(request) + # 返回结果 + return result + + +# 查询站点详情 +@router.get('/detail/{id}', summary='查询站点详情') +@permission_required("sys:item:detail") +def detail(request: Request, id): + # 调用查询站点详情服务方法 + data = item.get_item_detail(id) + # 返回结果 + return R.ok(data=data) + + +# 添加站点 +@router.post('/add', summary='添加站点') +@permission_required("sys:item:add") +@check_demo +def add(request: Request, data: ItemForm): + # 调用添加站点服务 + result = item.add_item(request, data) + # 返回结果 + return result + + +# 更新站点 +@router.put('/update', summary='更新站点') +@permission_required("sys:item:update") +@check_demo +def update(request: Request, data: ItemForm): + # 调用更新站点服务方法 + result = item.update_item(request, data) + # 返回结果 + return result + + +# 删除站点 +@router.delete('/delete/{id}', summary='删除站点') +@permission_required("sys:item:delete") +@check_demo +def delete(request: Request, id): + # 调用删除站点服务方法 + result = item.delete_item(id) + # 返回结果 + return result + + +# 获取站点列表 +@router.get('/getItemList', summary='获取站点列表') +def get_item_list(): + # 调用查询站点数据列表服务方法 + data = item.get_item_list() + # 返回结果 + return R.ok(data=data) + + +# 批量删除站点 +@router.delete('/batchDelete', summary='批量删除站点') +@permission_required("sys:item:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除站点 + + Request Body: + [1, 2, 3] # 站点ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(item.delete_item, id_str) diff --git a/apps/views/item_cate.py b/apps/views/item_cate.py new file mode 100644 index 0000000..013cfac --- /dev/null +++ b/apps/views/item_cate.py @@ -0,0 +1,92 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.forms.item_cate import ItemCateForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import item_cate +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:itemcate:page") +def page(request: Request): + # 调用查询分页数据服务方法 + result = item_cate.get_item_cate_page(request) + # 返回结果 + return result + + +# 查询栏目详情 +@router.get('/detail/{id}', summary='查询栏目详情') +@permission_required("sys:itemcate:detail") +def detail(request: Request, id): + # 调用查询栏目详情服务方法 + data = item_cate.get_item_cate_detail(id) + # 返回结果 + return R.ok(data=data) + + +# 添加栏目 +@router.post('/add', summary='添加栏目') +@permission_required("sys:itemcate:add") +@check_demo +def add(request: Request, data: ItemCateForm): + # 调用添加栏目服务 + result = item_cate.add_item_cate(request, data) + # 返回结果 + return result + + +# 更新栏目 +@router.put('/update', summary='更新栏目') +@permission_required("sys:itemcate:update") +@check_demo +def update(request: Request, data: ItemCateForm): + # 调用更新栏目服务方法 + result = item_cate.update_item_cate(request, data) + # 返回结果 + return result + + +# 删除栏目 +@router.delete('/delete/{id}', summary='删除栏目') +@permission_required("sys:itemcate:delete") +@check_demo +def delete(request: Request, id): + # 调用删除栏目服务方法 + result = item_cate.delete_item_cate(id) + # 返回结果 + return result + + +# 获取栏目列表 +@router.get('/getCateList', summary='获取栏目列表') +def get_cate_list(): + # 调用删除栏目服务方法 + result = item_cate.get_cate_list() + # 返回结果 + return R.ok(result) + + +# 批量删除栏目 +@router.delete('/batchDelete', summary='批量删除栏目') +@permission_required("sys:itemcate:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除栏目 + + Request Body: + [1, 2, 3] # 栏目ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(item_cate.delete_item_cate, id_str) diff --git a/apps/views/job.py b/apps/views/job.py new file mode 100644 index 0000000..f3d8ca2 --- /dev/null +++ b/apps/views/job.py @@ -0,0 +1,99 @@ +from fastapi import APIRouter, Request + +from apps.forms.job import JobForm, JobStatusForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import job + +from utils import R + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:job:page") +def page(request: Request): + result = job.get_job_page(request) + return result + + +# 查询任务详情 +@router.get('/detail/{id}', summary='查询任务详情') +@permission_required("sys:job:detail") +def detail(request: Request, id): + data = job.get_job_detail(id) + return R.ok(data=data) + + +# 添加定时任务 +@router.post('/add', summary='添加定时任务') +@permission_required("sys:job:add") +@check_demo +def add(request: Request, data: JobForm): + result = job.add_job(request, data) + return result + + +# 更新定时任务 +@router.put('/update', summary='更新定时任务') +@permission_required("sys:job:update") +@check_demo +def update(request: Request, data: JobForm): + result = job.update_job(request, data) + return result + + +# 删除定时任务 +@router.delete('/delete/{id}', summary='删除定时任务') +@permission_required("sys:job:delete") +@check_demo +def delete(request: Request, id): + result = job.delete_job(id) + return result + + +# 设置任务状态 +@router.put('/status', summary='设置任务状态') +@permission_required("sys:job:status") +@check_demo +def status(request: Request, data: JobStatusForm): + result = job.update_job_status(request, data) + return result + + +# 暂停任务 +@router.put('/pause/{id}', summary='暂停任务') +@permission_required("sys:job:pause") +@check_demo +def pause(request: Request, id): + result = job.pause_job_by_id(id) + return result + + +# 恢复任务 +@router.put('/resume/{id}', summary='恢复任务') +@permission_required("sys:job:resume") +@check_demo +def resume(request: Request, id): + result = job.resume_job_by_id(id) + return result + + +# 立即执行一次 +@router.post('/runOnce/{id}', summary='立即执行一次') +@permission_required("sys:job:run_once") +@check_demo +def run_once(request: Request, id): + result = job.run_job_once_api(id) + return result + + +# 重新加载调度器 +@router.post('/reload', summary='重新加载调度器') +@permission_required("sys:job:reload") +@check_demo +def reload(request: Request): + result = job.reload_all_jobs() + return result diff --git a/apps/views/job_log.py b/apps/views/job_log.py new file mode 100644 index 0000000..c133823 --- /dev/null +++ b/apps/views/job_log.py @@ -0,0 +1,55 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import job_log + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:job_log:page") +def page(request: Request): + result = job_log.get_job_log_page(request) + return result + + +# 查询日志详情 +@router.get('/detail/{id}', summary='查询日志详情') +@permission_required("sys:job_log:detail") +def detail(request: Request, id): + data = job_log.get_job_log_detail(id) + return R.ok(data=data) + + +# 删除任务日志 +@router.delete('/delete/{id}', summary='删除任务日志') +@permission_required("sys:job_log:delete") +@check_demo +def delete(request: Request, id): + result = job_log.delete_job_log(id) + return result + + +# 批量删除任务日志 +@router.delete('/batchDelete', summary='批量删除任务日志') +@permission_required("sys:job_log:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除任务日志 + + Request Body: + [1, 2, 3] # 日志ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(job_log.delete_job_log, id_str) diff --git a/apps/views/level.py b/apps/views/level.py new file mode 100644 index 0000000..2403d83 --- /dev/null +++ b/apps/views/level.py @@ -0,0 +1,108 @@ +import asyncio + +from fastapi import APIRouter, Request, UploadFile, File + +from apps.forms.level import LevelForm, LevelStatusForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import level + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:level:page") +def page(request: Request): + result = level.get_level_page(request) + return result + + +# 查询职级详情 +@router.get('/detail/{id}', summary='查询职级详情') +@permission_required("sys:level:detail") +def detail(request: Request, id): + data = level.get_level_detail(id) + return R.ok(data=data) + + +# 添加职级 +@router.post('/add', summary='添加职级') +@permission_required("sys:level:add") +@check_demo +def add(request: Request, data: LevelForm): + result = level.add_level(request, data) + return result + + +# 更新职级 +@router.put('/update', summary='更新职级') +@permission_required("sys:level:update") +@check_demo +def update(request: Request, data: LevelForm): + result = level.update_level(request, data) + return result + + +# 删除职级 +@router.delete('/delete/{id}', summary='删除职级') +@permission_required("sys:level:delete") +@check_demo +def delete(request: Request, id): + result = level.delete_level(id) + return result + + +# 设置职级状态 +@router.put('/status', summary='设置职级状态') +@permission_required("sys:level:status") +@check_demo +def status(request: Request, data: LevelStatusForm): + result = level.update_level_status(request, data) + return result + + +# 导入Excel +@router.post('/import', summary='导入Excel') +@permission_required("sys:level:import") +@check_demo +async def imports(request: Request, file: UploadFile = File(...)): + result = await level.import_level(request, file) + return result + + +# 导出Excel +@router.post('/export', summary='导出Excel') +@permission_required("sys:level:export") +@check_demo +def export(request: Request): + result = level.export_level(request) + return result + + +# 获取职级列表 +@router.get('/getLevelList', summary='获取职级列表') +def get_level_list(request: Request): + result = level.get_level_list() + return R.ok(data=result) + + +# 批量删除职级 +@router.delete('/batchDelete', summary='批量删除职级') +@permission_required("sys:level:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除职级 + + Request Body: + [1, 2, 3] # 职级ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(level.delete_level, id_str) diff --git a/apps/views/link.py b/apps/views/link.py new file mode 100644 index 0000000..af07793 --- /dev/null +++ b/apps/views/link.py @@ -0,0 +1,95 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.forms.link import LinkForm, LinkStatusForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import link + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:link:page") +def page(request: Request): + # 调用查询分页数据服务方法 + result = link.get_link_page(request) + # 返回结果 + return result + + +# 查询友链详情 +@router.get('/detail/{id}', summary='查询友链详情') +@permission_required("sys:link:detail") +def detail(request: Request, id): + # 调用查询友链详情服务方法 + data = link.get_link_detail(id) + # 返回结果 + return R.ok(data=data) + + +# 添加友链 +@router.post('/add', summary='添加友链') +@permission_required("sys:link:add") +@check_demo +def add(request: Request, data: LinkForm): + # 调用添加友链服务 + result = link.add_link(request, data) + # 返回结果 + return result + + +# 更新友链 +@router.put('/update', summary='更新友链') +@permission_required("sys:link:update") +@check_demo +def update(request: Request, data: LinkForm): + # 调用更新友链服务方法 + result = link.update_link(request, data) + # 返回结果 + return result + + +# 删除友链 +@router.delete('/delete/{id}', summary='删除友链') +@permission_required("sys:link:delete") +@check_demo +def delete(request: Request, id): + # 调用删除友链服务方法 + result = link.delete_link(id) + # 返回结果 + return result + + +# 设置友链状态 +@router.put('/status', summary='设置友链状态') +@permission_required("sys:link:status") +@check_demo +def status(request: Request, data: LinkStatusForm): + # 调用设置友链状态服务方法 + result = link.update_link_status(request, data) + # 返回结果 + return result + + +# 批量删除友链 +@router.delete('/batchDelete', summary='批量删除友链') +@permission_required("sys:link:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除友链 + + Request Body: + [1, 2, 3] # 友链ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(link.delete_link, id_str) diff --git a/apps/views/login.py b/apps/views/login.py new file mode 100644 index 0000000..5a2e7be --- /dev/null +++ b/apps/views/login.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Request + +from apps.forms.login import LoginForm +from apps.services import login +from utils import R +from utils.captcha import generate_captcha + +# 创建路由 +router = APIRouter() + + +# 系统登录 +@router.post('/login', summary='系统登录') +async def do_login(request: Request, data: LoginForm): + result = await login.login(request, data) + return result + + +# 获取验证码 +@router.get('/captcha', summary='获取验证码') +async def captcha(request: Request): + # 生成验证码 + result = await generate_captcha(request) + # idKey值 + idKey = result['idKey'] + # 验证码base64字符串 + data = result['data'] + # 将图片转换为base64 + imgStr = "data:image/png;base64," + data + # 返回结果 + return R.ok(idkey=idKey, data=imgStr) diff --git a/apps/views/login_log.py b/apps/views/login_log.py new file mode 100644 index 0000000..fd53a7e --- /dev/null +++ b/apps/views/login_log.py @@ -0,0 +1,47 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import login_log + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:login_log:page") +def page(request: Request): + result = login_log.get_login_log_page(request) + return result + + +# 删除登录日志 +@router.delete('/delete/{id}', summary='删除登录日志') +@permission_required("sys:login_log:delete") +@check_demo +def delete(request: Request, id): + result = login_log.delete_login_log(id) + return result + + +# 批量删除登录日志 +@router.delete('/batchDelete', summary='批量删除登录日志') +@permission_required("sys:login_log:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除登录日志 + + Request Body: + [1, 2, 3] # 日志ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(login_log.delete_login_log, id_str) diff --git a/apps/views/member.py b/apps/views/member.py new file mode 100644 index 0000000..f2e7cce --- /dev/null +++ b/apps/views/member.py @@ -0,0 +1,96 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.forms.member import MemberForm, MemberStatusForm +from apps.middleware.permission_required import permission_required +from apps.middleware.check_demo import check_demo + +from apps.services import member + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:member:page") +def page(request: Request): + # 调用查询分页数据服务方法 + result = member.get_member_page(request) + # 返回结果 + return result + + +# 查询会员详情 +@router.get('/detail/{id}', summary='查询会员详情') +@permission_required("sys:member:detail") +def detail(request: Request, id): + # 调用查询会员详情服务方法 + data = member.get_member_detail(id) + # 返回结果 + return R.ok(data=data) + + +# 添加会员 +@router.post('/add', summary='添加会员') +@permission_required("sys:member:add") +@check_demo +def add(request: Request, data: MemberForm): + # 调用添加会员服务 + result = member.add_member(request, data) + # 返回结果 + return result + + +# 更新会员 +@router.put('/update', summary='更新会员') +@permission_required("sys:member:update") +@check_demo +def update(request: Request, data: MemberForm): + # 调用更新会员服务方法 + result = member.update_member(request, data) + # 返回结果 + return result + + +# 删除会员 +@router.delete('/delete/{id}', summary='删除会员') +@permission_required("sys:member:delete") +@check_demo +def delete(request: Request, id): + # 调用删除会员服务方法 + result = member.delete_member(id) + # 返回结果 + return result + + +# 设置会员状态 +@router.put('/status', summary='设置会员状态') +@permission_required("sys:member:status") +@check_demo +def status(request: Request, data: MemberStatusForm): + # 调用设置会员状态服务方法 + result = member.update_member_status(request, data) + # 返回结果 + return result + + +# 批量删除会员 +@router.delete('/batchDelete', summary='批量删除会员') +@permission_required("sys:member:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除会员 + + Request Body: + [1, 2, 3] # 会员ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(member.delete_member, id_str) diff --git a/apps/views/member_level.py b/apps/views/member_level.py new file mode 100644 index 0000000..6a09a08 --- /dev/null +++ b/apps/views/member_level.py @@ -0,0 +1,93 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.forms.member_level import MemberLevelForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import member_level + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:memberlevel:page") +def page(request: Request): + # 调用查询分页数据服务方法 + result = member_level.get_member_level_page(request) + # 返回结果 + return result + + +# 查询会员等级详情 +@router.get('/detail/{id}', summary='查询会员等级详情') +@permission_required("sys:memberlevel:detail") +def detail(request: Request, id): + # 调用查询会员等级详情服务方法 + data = member_level.get_member_level_detail(id) + # 返回结果 + return R.ok(data=data) + + +# 添加会员等级 +@router.post('/add', summary='添加会员等级') +@permission_required("sys:memberlevel:add") +@check_demo +def add(request: Request, data: MemberLevelForm): + # 调用添加会员等级服务 + result = member_level.add_member_level(request, data) + # 返回结果 + return result + + +# 更新会员等级 +@router.put('/update', summary='更新会员等级') +@permission_required("sys:memberlevel:update") +@check_demo +def update(request: Request, data: MemberLevelForm): + # 调用更新会员等级服务方法 + result = member_level.update_member_level(request, data) + # 返回结果 + return result + + +# 删除会员等级 +@router.delete('/delete/{id}', summary='删除会员等级') +@permission_required("sys:memberlevel:delete") +@check_demo +def delete(request: Request, id): + # 调用删除会员等级服务方法 + result = member_level.delete_member_level(id) + # 返回结果 + return result + + +# 获取会员等级列表 +@router.get('/getMemberLevelList', summary='获取会员等级列表') +def get_member_level_list(): + # 调用查询会员等级列表服务方法 + data = member_level.get_member_level_list() + # 返回结果 + return R.ok(data=data) + + +# 批量删除会员等级 +@router.delete('/batchDelete', summary='批量删除会员等级') +@permission_required("sys:memberlevel:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除会员等级 + + Request Body: + [1, 2, 3] # 会员等级ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(member_level.delete_member_level, id_str) diff --git a/apps/views/menu.py b/apps/views/menu.py new file mode 100644 index 0000000..75a5631 --- /dev/null +++ b/apps/views/menu.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter, Request + +from apps.forms.menu import MenuForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import menu +from utils import R + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/list', summary='查询分页数据') +@permission_required("sys:menu:list") +def list(request: Request): + result = menu.get_menu_page(request) + return result + + +# 查询菜单详情 +@router.get('/detail/{id}', summary='查询菜单详情') +@permission_required("sys:menu:detail") +def detail(request: Request, id): + data = menu.get_menu_detail(id) + return R.ok(data=data) + + +# 添加菜单 +@router.post('/add', summary='添加菜单') +@permission_required("sys:menu:add") +@check_demo +async def add(request: Request, data: MenuForm): + result = await menu.add_menu(request, data) + return result + + +# 更新菜单 +@router.put('/update', summary='更新菜单') +@permission_required("sys:menu:update") +@check_demo +async def update(request: Request, data: MenuForm): + result = await menu.update_menu(request, data) + return result + + +# 删除菜单 +@router.delete('/delete/{id}', summary='删除菜单') +@permission_required("sys:menu:delete") +@check_demo +async def delete(request: Request, id): + result = await menu.delete_menu(id) + return result diff --git a/apps/views/notice.py b/apps/views/notice.py new file mode 100644 index 0000000..1bdb14d --- /dev/null +++ b/apps/views/notice.py @@ -0,0 +1,84 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.forms.notice import NoticeForm +from apps.middleware.permission_required import permission_required +from apps.middleware.check_demo import check_demo +from apps.services import notice + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:notice:page") +def page(request: Request): + # 调用查询分页数据服务方法 + result = notice.get_notice_page(request) + # 返回结果 + return result + + +# 查询通知详情 +@router.get('/detail/{id}', summary='查询通知详情') +@permission_required("sys:notice:detail") +def detail(request: Request, id): + # 调用查询通知详情服务方法 + data = notice.get_notice_detail(id) + # 返回结果 + return R.ok(data=data) + + +# 添加通知 +@router.post('/add', summary='添加通知') +@permission_required("sys:notice:add") +@check_demo +def add(request: Request, data: NoticeForm): + # 调用添加通知服务 + result = notice.add_notice(request, data) + # 返回结果 + return result + + +# 更新通知 +@router.put('/update', summary='更新通知') +@permission_required("sys:notice:update") +@check_demo +def update(request: Request, data: NoticeForm): + # 调用更新通知服务方法 + result = notice.update_notice(request, data) + # 返回结果 + return result + + +# 删除通知 +@router.delete('/delete/{id}', summary='删除通知') +@permission_required("sys:notice:delete") +@check_demo +def delete(request: Request, id): + # 调用删除通知服务方法 + result = notice.delete_notice(id) + # 返回结果 + return result + + +# 批量删除通知 +@router.delete('/batchDelete', summary='批量删除通知') +@permission_required("sys:notice:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除通知 + + Request Body: + [1, 2, 3] # 通知ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(notice.delete_notice, id_str) diff --git a/apps/views/operation_log.py b/apps/views/operation_log.py new file mode 100644 index 0000000..7a3c99c --- /dev/null +++ b/apps/views/operation_log.py @@ -0,0 +1,47 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import operation_log + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:operation_log:page") +def page(request: Request): + result = operation_log.get_operation_log_page(request) + return result + + +# 删除操作日志 +@router.delete('/delete/{id}', summary='删除操作日志') +@permission_required("sys:operation_log:delete") +@check_demo +def delete(request: Request, id): + result = operation_log.delete_operation_log(id) + return result + + +# 批量删除操作日志 +@router.delete('/batchDelete', summary='批量删除操作日志') +@permission_required("sys:operation_log:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除操作日志 + + Request Body: + [1, 2, 3] # 日志ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(operation_log.delete_operation_log, id_str) diff --git a/apps/views/position.py b/apps/views/position.py new file mode 100644 index 0000000..0f2d380 --- /dev/null +++ b/apps/views/position.py @@ -0,0 +1,90 @@ +import asyncio + +from fastapi import APIRouter, Request + +from apps.forms.position import PositionForm, PositionStatusForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import position + +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:position:page") +def page(request: Request): + result = position.get_position_page(request) + return result + + +# 查询岗位详情 +@router.get('/detail/{id}', summary='查询岗位详情') +@permission_required("sys:position:detail") +def detail(request: Request, id): + data = position.get_position_detail(id) + return R.ok(data=data) + + +# 添加岗位 +@router.post('/add', summary='添加岗位') +@permission_required("sys:position:add") +@check_demo +def add(request: Request, data: PositionForm): + result = position.add_position(request, data) + return result + + +# 更新岗位 +@router.put('/update', summary='更新职级') +@permission_required("sys:position:update") +@check_demo +def update(request: Request, data: PositionForm): + result = position.update_position(request, data) + return result + + +# 删除岗位 +@router.delete('/delete/{id}', summary='删除岗位') +@permission_required("sys:position:delete") +@check_demo +def delete(request: Request, id): + result = position.delete_position(id) + return result + + +# 设置岗位状态 +@router.put('/status', summary='设置岗位状态') +@permission_required("sys:position:status") +@check_demo +def status(request: Request, data: PositionStatusForm): + result = position.update_position_status(request, data) + return result + + +# 获取岗位列表 +@router.get('/getPositionList', summary='获取岗位列表') +def get_position_list(): + result = position.get_position_list() + return R.ok(data=result) + + +# 批量删除岗位 +@router.delete('/batchDelete', summary='批量删除岗位') +@permission_required("sys:position:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除岗位 + + Request Body: + [1, 2, 3] # 岗位ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(position.delete_position, id_str) diff --git a/apps/views/role.py b/apps/views/role.py new file mode 100644 index 0000000..2443099 --- /dev/null +++ b/apps/views/role.py @@ -0,0 +1,101 @@ +from fastapi import APIRouter, Request + +from apps.forms.role import RoleForm, RoleStatusForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import role +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:role:page") +def page(request: Request): + # 调用查询分页数据服务方法 + result = role.get_role_page(request) + # 返回结果 + return result + + +# 查询角色详情 +@router.get('/detail/{id}', summary='查询角色详情') +@permission_required("sys:role:detail") +def detail(request: Request, id): + # 调用查询角色详情服务方法 + data = role.get_role_detail(id) + # 返回结果 + return R.ok(data=data) + + +# 添加角色 +@router.post('/add', summary='添加角色') +@permission_required("sys:role:add") +@check_demo +def add(request: Request, data: RoleForm): + # 调用添加角色服务 + result = role.add_role(request, data) + # 返回结果 + return result + + +# 更新角色 +@router.put('/update', summary='更新角色') +@permission_required("sys:role:update") +@check_demo +def update(request: Request, data: RoleForm): + # 调用更新角色服务方法 + result = role.update_role(request, data) + # 返回结果 + return result + + +# 删除角色 +@router.delete('/delete/{id}', summary='删除角色') +@permission_required("sys:role:delete") +@check_demo +async def delete(request: Request, id): + # 调用删除角色服务方法 + result = await role.delete_role(id) + # 返回结果 + return result + + +# 设置角色状态 +@router.put('/status', summary='设置角色状态') +@permission_required("sys:role:status") +@check_demo +def status(request: Request, data: RoleStatusForm): + # 调用设置角色状态服务方法 + result = role.update_role_status(request, data) + # 返回结果 + return result + + +# 获取角色列表 +@router.get('/getRoleList', summary='获取角色列表') +def get_role_list(request: Request): + # 调用获取角色列表方法 + result = role.get_role_list() + # 返回结果 + return R.ok(data=result) + + +# 批量删除角色 +@router.delete('/batchDelete', summary='批量删除角色') +@permission_required("sys:role:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除角色 + + Request Body: + [1, 2, 3] # 角色ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await role.delete_role(id_str) diff --git a/apps/views/role_menu.py b/apps/views/role_menu.py new file mode 100644 index 0000000..28f4c52 --- /dev/null +++ b/apps/views/role_menu.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter, Request + +from apps.forms.role_menu import RoleMenuForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import role_menu +from utils import R + +# 创建路由 +router = APIRouter() + + +# 查询角色菜单列表 +@router.get('/index/{role_id}', summary='查询角色菜单列表') +@permission_required("sys:rolemenu:index") +def index(request: Request, role_id): + menu_list = role_menu.get_role_menu_list(role_id) + return R.ok(data=menu_list) + + +# 保存角色菜单数据 +@router.post('/save', summary='保存角色菜单数据') +@permission_required("sys:rolemenu:save") +@check_demo +async def save(request: Request, data: RoleMenuForm): + result = await role_menu.save_role_menu(request, data) + return result diff --git a/apps/views/upload.py b/apps/views/upload.py new file mode 100644 index 0000000..c843120 --- /dev/null +++ b/apps/views/upload.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter, UploadFile, File + +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import upload + +# 创建路由 +router = APIRouter() + + +# 上传图片 +@router.post('/uploadImage', summary='上传图片') +@permission_required("sys:upload:image") +@check_demo +async def upload_image(file: UploadFile = File(...)): + return await upload.upload_image(file) + + +# 上传文件 +@router.post('/uploadFile', summary='上传文件') +@permission_required("sys:upload:file") +@check_demo +async def upload_file(file: UploadFile = File(...)): + return await upload.upload_file(file) + + +# 富文本图片上传 +@router.post('/uploadEditImage', summary='富文本图片上传') +@permission_required("sys:upload:image") +@check_demo +async def upload_edit_image(file: UploadFile = File(...)): + result = await upload.upload_image(file) + # 兼容富文本编辑器返回格式 + if result.status_code == 200: + import json + body = json.loads(result.body) + if body.get('code') == 0 and body.get('data'): + file_url = body['data'].get('fileUrl', '') + from utils import R + return R.ok(error=0, url=file_url) + return result diff --git a/apps/views/user.py b/apps/views/user.py new file mode 100644 index 0000000..6988b3f --- /dev/null +++ b/apps/views/user.py @@ -0,0 +1,107 @@ +import asyncio + +from fastapi import APIRouter + +from starlette.requests import Request + +from apps.forms.user import UserForm, UserStatusForm, ResetPwdForm +from apps.middleware.check_demo import check_demo +from apps.middleware.permission_required import permission_required +from apps.services import user +from utils import R +from utils.request import parse_batch_ids + +# 创建路由 +router = APIRouter() + + +# 查询分页数据 +@router.get('/page', summary='查询分页数据') +@permission_required("sys:user:page") +def page(request: Request): + # 调用查询分页数据服务方法 + result = user.get_user_page(request) + # 返回结果 + return result + + +# 查询用户详情 +@router.get('/detail/{id}', summary='查询用户详情') +@permission_required("sys:user:detail") +def detail(request: Request, id): + # 调用查询用户详情服务方法 + data = user.get_user_detail(id) + # 返回结果 + return R.ok(data=data) + + +# 添加用户 +@router.post('/add', summary='添加用户') +@permission_required("sys:user:add") +@check_demo +async def add(request: Request, data: UserForm): + # 调用添加用户服务 + result = await user.add_user(request, data) + # 返回结果 + return result + + +# 更新用户 +@router.put('/update', summary='更新用户') +@permission_required("sys:user:update") +@check_demo +async def update(request: Request, data: UserForm): + # 调用更新用户服务方法 + result = await user.update_user(request, data) + # 返回结果 + return result + + +# 删除用户 +@router.delete('/delete/{id}', summary='删除用户') +@permission_required("sys:user:delete") +@check_demo +def delete(request: Request, id): + # 调用删除用户服务方法 + result = user.delete_user(id) + # 返回结果 + return result + + +# 设置用户状态 +@router.put('/status', summary='设置用户状态') +@permission_required("sys:user:status") +@check_demo +def status(request: Request, data: UserStatusForm): + # 调用设置用户状态服务方法 + result = user.update_user_status(request, data) + # 返回结果 + return result + + +# 重置密码 +@router.post('/resetPwd', summary='重置密码') +@permission_required("sys:user:resetPwd") +@check_demo +def reset_pwd(request: Request, data: ResetPwdForm): + # 调用重置密码服务方法 + result = user.reset_user_pwd(request, data) + # 返回结果 + return result + + +# 批量删除用户 +@router.delete('/batchDelete', summary='批量删除用户') +@permission_required("sys:user:batch_delete") +@check_demo +async def batch_delete(request: Request): + """ + 批量删除用户 + + Request Body: + [1, 2, 3] # 用户ID数组 + """ + id_str, err = await parse_batch_ids(request) + if err: + return err + return await asyncio.to_thread(user.delete_user, id_str) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..06d2a19 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,104 @@ +import os +from dotenv import load_dotenv + + +def _load_env_file(): + """加载 .env 配置文件""" + root_path = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) + env_path = os.path.join(root_path, '.env') + if os.path.exists(env_path): + load_dotenv(env_path) + return True + return False + + +# 在任何 config 子模块被 import 之前加载 .env +_load_env_file() + +# ============================================================ +# 重新导出所有子模块配置(统一入口) +# ============================================================ + +# 应用基础配置 +from config.app import ( + FASTAPI_NAME, FASTAPI_VERSION, FASTAPI_HOST, FASTAPI_PORT, + FASTAPI_APP, FASTAPI_ENV, FASTAPI_DEBUG, FASTAPI_DEMO, + FASTAPI_ROOT_PATH, FASTAPI_TEMPLATE_FOLDER, FASTAPI_STATIC_FOLDER, + FASTAPI_UPLOAD_DIR, FASTAPI_IMAGE_PATH, FASTAPI_TEMP_PATH, FASTAPI_IMAGE_URL, + TRUSTED_PROXY_COUNT, +) + +# 数据库配置 +from config.database import ( + DB_DRIVER, DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD, + DB_PREFIX, DB_DEBUG, DB_POOL_SIZE, DB_MAX_OVERFLOW, DB_POOL_RECYCLE, + DB_POOL_TIMEOUT, DB_CONNECT_TIMEOUT, SQLALCHEMY_MYSQL_URL, + SQLALCHEMY_POSTGRESQL_URL, SQLALCHEMY_SQLITE_URL, +) + +# Redis 缓存配置 +from config.redis import ( + REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_INDEX, + REDIS_AUTH, REDIS_DECODE_RESPONSES, +) + +# JWT 认证配置 +from config.auth import ( + JWT_SALT, JWT_EXPIRE_MINUTES, + LOGIN_FAIL_MAX, LOGIN_LOCK_SECONDS, +) + +# 验证码配置 +from config.captcha import ( + CAPTCHA_LENGTH, CAPTCHA_EXPIRE, CAPTCHA_RATE_LIMIT, CAPTCHA_RATE_WINDOW, + CAPTCHA_CHARS, CAPTCHA_FONT_PATH, CAPTCHA_IMAGE_WIDTH, CAPTCHA_IMAGE_HEIGHT, + CAPTCHA_FONT_SIZE, CAPTCHA_NOISE_LINES, CAPTCHA_NOISE_DOTS, CAPTCHA_BYPASS_CODE, +) + +# 邮件配置 +from config.email import ( + MAIL_MAILER, MAIL_SERVER, MAIL_PORT, MAIL_USE_SSL, + MAIL_USERNAME, MAIL_PASSWORD, MAIL_ENCRYPTION, + MAIL_FROM_NAME, MAIL_FROM_ADDRESS, +) + +# 短信配置 +from config.sms import ( + SMS_URL, SMS_API_KEY, SMS_SIGN, SMS_TEMPLATE_ID, +) + +__all__ = [ + # 应用基础配置 + 'FASTAPI_NAME', 'FASTAPI_VERSION', 'FASTAPI_HOST', 'FASTAPI_PORT', + 'FASTAPI_APP', 'FASTAPI_ENV', 'FASTAPI_DEBUG', 'FASTAPI_DEMO', + 'FASTAPI_ROOT_PATH', 'FASTAPI_TEMPLATE_FOLDER', 'FASTAPI_STATIC_FOLDER', + 'FASTAPI_UPLOAD_DIR', 'FASTAPI_IMAGE_PATH', 'FASTAPI_TEMP_PATH', 'FASTAPI_IMAGE_URL', + 'TRUSTED_PROXY_COUNT', + + # 数据库配置 + 'DB_DRIVER', 'DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USERNAME', 'DB_PASSWORD', + 'DB_PREFIX', 'DB_DEBUG', 'DB_POOL_SIZE', 'DB_MAX_OVERFLOW', 'DB_POOL_RECYCLE', + 'DB_POOL_TIMEOUT', 'DB_CONNECT_TIMEOUT', 'SQLALCHEMY_MYSQL_URL', + 'SQLALCHEMY_POSTGRESQL_URL', 'SQLALCHEMY_SQLITE_URL', + + # Redis 配置 + 'REDIS_HOST', 'REDIS_PORT', 'REDIS_PASSWORD', 'REDIS_INDEX', + 'REDIS_AUTH', 'REDIS_DECODE_RESPONSES', + + # JWT 认证配置 + 'JWT_SALT', 'JWT_EXPIRE_MINUTES', + 'LOGIN_FAIL_MAX', 'LOGIN_LOCK_SECONDS', + + # 验证码配置 + 'CAPTCHA_LENGTH', 'CAPTCHA_EXPIRE', 'CAPTCHA_RATE_LIMIT', 'CAPTCHA_RATE_WINDOW', + 'CAPTCHA_CHARS', 'CAPTCHA_FONT_PATH', 'CAPTCHA_IMAGE_WIDTH', 'CAPTCHA_IMAGE_HEIGHT', + 'CAPTCHA_FONT_SIZE', 'CAPTCHA_NOISE_LINES', 'CAPTCHA_NOISE_DOTS', 'CAPTCHA_BYPASS_CODE', + + # 邮件配置 + 'MAIL_MAILER', 'MAIL_SERVER', 'MAIL_PORT', 'MAIL_USE_SSL', + 'MAIL_USERNAME', 'MAIL_PASSWORD', 'MAIL_ENCRYPTION', + 'MAIL_FROM_NAME', 'MAIL_FROM_ADDRESS', + + # 短信配置 + 'SMS_URL', 'SMS_API_KEY', 'SMS_SIGN', 'SMS_TEMPLATE_ID', +] diff --git a/config/app.py b/config/app.py new file mode 100644 index 0000000..b61f51d --- /dev/null +++ b/config/app.py @@ -0,0 +1,49 @@ +import logging +import os + +_logger = logging.getLogger(__name__) + +# 应用名称 +FASTAPI_NAME = os.getenv('FASTAPI_NAME', 'FastAPI+EleVue旗舰版') +# 应用版本 +FASTAPI_VERSION = os.getenv('FASTAPI_VERSION', 'v2.8.0') +# 应用运行地址 +FASTAPI_HOST = os.getenv('FASTAPI_HOST', '127.0.0.1') +# 应用运行端口 +FASTAPI_PORT = os.getenv('FASTAPI_PORT', 8031) + +# 应用启动文件 +FASTAPI_APP = os.getenv('FASTAPI_APP', 'main.py') +# 应用环境变量 +FASTAPI_ENV = os.getenv('FASTAPI_ENV', 'development') +# 是否调试模式 +FASTAPI_DEBUG = (os.getenv('FASTAPI_DEBUG', 'False') == 'True') +# 是否演示模式 +FASTAPI_DEMO = (os.getenv('FASTAPI_DEMO', 'False') == 'True') + +# 应用根目录 +FASTAPI_ROOT_PATH = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) +# 应用模板路径 +FASTAPI_TEMPLATE_FOLDER = os.path.join(FASTAPI_ROOT_PATH, 'templates') +# 应用静态资源路径 +FASTAPI_STATIC_FOLDER = os.path.join(FASTAPI_ROOT_PATH, 'static') +# 应用文件存储路径 +FASTAPI_UPLOAD_DIR = os.getenv('FASTAPI_UPLOAD_DIR', os.path.join(FASTAPI_ROOT_PATH, 'uploads')) +# 正式图片路径 +FASTAPI_IMAGE_PATH = os.path.join(FASTAPI_UPLOAD_DIR, 'images') +# 临时文件路径 +FASTAPI_TEMP_PATH = os.path.join(FASTAPI_UPLOAD_DIR, 'temp') +# 应用图片域名 +FASTAPI_IMAGE_URL = os.getenv('FASTAPI_IMAGE_URL', '') + +# CORS允许的源(逗号分隔),未配置则允许所有源但不携带凭证 +CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', '') + +# 可信代理层数(0=不信任X-Forwarded-For,1=一层反向代理,2=两层…) +# 仅当应用部署在Nginx等反向代理后才应设置为>=1 +TRUSTED_PROXY_COUNT = int(os.getenv('TRUSTED_PROXY_COUNT', '0')) + +# 允许上传的文件扩展名(逗号分隔,带点号) +UPLOAD_ALLOWED_EXTS = os.getenv('UPLOAD_ALLOWED_EXTS', '.jpg,.jpeg,.png,.gif,.bmp,.webp,.svg,.ico,.txt,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4,.zip,.rar,.7z') +# 上传文件大小上限(MB) +UPLOAD_MAX_SIZE_MB = int(os.getenv('UPLOAD_MAX_SIZE_MB', '10')) diff --git a/config/auth.py b/config/auth.py new file mode 100644 index 0000000..20f1b03 --- /dev/null +++ b/config/auth.py @@ -0,0 +1,12 @@ +import os + +# JWT签名密钥(必须通过环境变量设置,不能使用默认值上线) +JWT_SALT = os.getenv('JWT_SALT', '') +# JWT令牌过期时间(分钟) +JWT_EXPIRE_MINUTES = int(os.getenv('JWT_EXPIRE_MINUTES', '20')) + +# 登录防暴力破解配置 +# 登录失败次数上限(超过后锁定账户) +LOGIN_FAIL_MAX = int(os.getenv('LOGIN_FAIL_MAX', '5')) +# 登录锁定时间(秒) +LOGIN_LOCK_SECONDS = int(os.getenv('LOGIN_LOCK_SECONDS', '300')) diff --git a/config/captcha.py b/config/captcha.py new file mode 100644 index 0000000..08fcc49 --- /dev/null +++ b/config/captcha.py @@ -0,0 +1,26 @@ +import os + +# 验证码字符长度 +CAPTCHA_LENGTH = int(os.getenv('CAPTCHA_LENGTH', '6')) +# 验证码过期时间(秒) +CAPTCHA_EXPIRE = int(os.getenv('CAPTCHA_EXPIRE', '300')) +# 验证码防刷窗口期(秒) +CAPTCHA_RATE_WINDOW = int(os.getenv('CAPTCHA_RATE_WINDOW', '60')) +# 验证码防刷上限(窗口期内最大获取次数) +CAPTCHA_RATE_LIMIT = int(os.getenv('CAPTCHA_RATE_LIMIT', '10')) +# 验证码字符集 +CAPTCHA_CHARS = os.getenv('CAPTCHA_CHARS', 'ABCDEFGHJKLMNPQRSTUVWXYZ2346789') +# 验证码字体路径 +CAPTCHA_FONT_PATH = os.getenv('CAPTCHA_FONT_PATH', 'static/fonts/Vera.ttf') +# 验证码图片宽度 +CAPTCHA_IMAGE_WIDTH = int(os.getenv('CAPTCHA_IMAGE_WIDTH', '160')) +# 验证码图片高度 +CAPTCHA_IMAGE_HEIGHT = int(os.getenv('CAPTCHA_IMAGE_HEIGHT', '50')) +# 验证码字体大小 +CAPTCHA_FONT_SIZE = int(os.getenv('CAPTCHA_FONT_SIZE', '28')) +# 干扰线数量 +CAPTCHA_NOISE_LINES = int(os.getenv('CAPTCHA_NOISE_LINES', '6')) +# 噪点数量 +CAPTCHA_NOISE_DOTS = int(os.getenv('CAPTCHA_NOISE_DOTS', '30')) +# 万能验证码(仅 FASTAPI_DEMO=True 时生效,方便演示环境登录) +CAPTCHA_BYPASS_CODE = os.getenv('CAPTCHA_BYPASS_CODE', '618618') diff --git a/config/database.py b/config/database.py new file mode 100644 index 0000000..8771efa --- /dev/null +++ b/config/database.py @@ -0,0 +1,38 @@ +import os +from urllib.parse import quote_plus + +# 数据库驱动 +DB_DRIVER = os.getenv('DB_DRIVER', 'mysql') +# 数据库地址 +DB_HOST = os.getenv('DB_HOST', '127.0.0.1') +# 数据库端口 +DB_PORT = int(os.getenv('DB_PORT', '3306')) +# 数据库名称 +DB_DATABASE = os.getenv('DB_DATABASE', 'djangoadmin.fastapi.elevue') +# 数据库账号 +DB_USERNAME = os.getenv('DB_USERNAME', 'root') +# 数据库密码 +DB_PASSWORD = os.getenv('DB_PASSWORD', '') +# 数据表前缀 +DB_PREFIX = os.getenv('DB_PREFIX', 'fastapi_') +# 是否开启调试模式 +DB_DEBUG = (os.getenv('DB_DEBUG', 'True') == 'True') + +# 连接池大小(每个 worker 保持的常驻连接数) +DB_POOL_SIZE = int(os.getenv('DB_POOL_SIZE', '20')) +# 连接池最大溢出(超出 pool_size 后允许临时创建的连接数) +DB_MAX_OVERFLOW = int(os.getenv('DB_MAX_OVERFLOW', '2')) +# 连接回收时间(秒),应小于 MySQL wait_timeout +DB_POOL_RECYCLE = int(os.getenv('DB_POOL_RECYCLE', '1800')) +# 获取连接超时(秒),池满且无可用连接时的等待时间 +DB_POOL_TIMEOUT = int(os.getenv('DB_POOL_TIMEOUT', '15')) +# 连接建立超时(秒) +DB_CONNECT_TIMEOUT = int(os.getenv('DB_CONNECT_TIMEOUT', '60')) + +# MySQL数据库链接 +SQLALCHEMY_MYSQL_URL = 'mysql+pymysql://' + quote_plus(DB_USERNAME) + ':' + quote_plus(DB_PASSWORD) + '@' + DB_HOST + ':' + str( + DB_PORT) + '/' + DB_DATABASE + '?charset=utf8mb4' +# PostgreSQL数据库链接(预留扩展) +SQLALCHEMY_POSTGRESQL_URL = "postgresql://user:password@postgresserver/db" +# SQLite数据库链接(预留扩展) +SQLALCHEMY_SQLITE_URL = "sqlite:///./test.db" diff --git a/config/email.py b/config/email.py new file mode 100644 index 0000000..0207b47 --- /dev/null +++ b/config/email.py @@ -0,0 +1,20 @@ +import os + +# 邮寄者 +MAIL_MAILER = os.getenv('MAIL_MAILER', 'smtp') +# 邮件服务 +MAIL_SERVER = os.getenv('MAIL_SERVER', 'smtp.163.com') +# 邮件端口 +MAIL_PORT = os.getenv('MAIL_PORT', 465) +# 邮件SSL证书 +MAIL_USE_SSL = (os.getenv('MAIL_USE_SSL', 'True') == 'True') +# 授权邮箱用户名 +MAIL_USERNAME = os.getenv('MAIL_USERNAME', '') +# 授权邮箱授权码 +MAIL_PASSWORD = os.getenv('MAIL_PASSWORD', '') +# 邮件加密串 +MAIL_ENCRYPTION = os.getenv('MAIL_ENCRYPTION', '') +# 邮件发件人名称 +MAIL_FROM_NAME = os.getenv('MAIL_FROM_NAME', '') +# 邮件发件人地址 +MAIL_FROM_ADDRESS = os.getenv('MAIL_FROM_ADDRESS', '') diff --git a/config/redis.py b/config/redis.py new file mode 100644 index 0000000..67ccc0e --- /dev/null +++ b/config/redis.py @@ -0,0 +1,14 @@ +import os + +# 缓存服务地址 +REDIS_HOST = os.getenv('REDIS_HOST', '127.0.0.1') +# 缓存服务端口 +REDIS_PORT = os.getenv('REDIS_PORT', 6379) +# 缓存服务密码 +REDIS_PASSWORD = os.getenv('REDIS_PASSWORD', '') +# 缓存库索引 +REDIS_INDEX = os.getenv('REDIS_INDEX', 0) +# AUTH 为 True 时需要进行用户认证 +REDIS_AUTH = (os.getenv('REDIS_AUTH', 'True') == 'True') +# 是否对查询结果进行编码处理 +REDIS_DECODE_RESPONSES = (os.getenv('REDIS_DECODE_RESPONSES', 'True') == 'True') diff --git a/config/sms.py b/config/sms.py new file mode 100644 index 0000000..56bd8d5 --- /dev/null +++ b/config/sms.py @@ -0,0 +1,10 @@ +import os + +# 短信服务地址 +SMS_URL = os.getenv('SMS_URL', '') +# 短信服务API密钥 +SMS_API_KEY = os.getenv('SMS_API_KEY', '') +# 短信签名 +SMS_SIGN = os.getenv('SMS_SIGN', '') +# 短信模板ID +SMS_TEMPLATE_ID = os.getenv('SMS_TEMPLATE_ID', '') diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4d709d8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +version: '3.7' + +# 应用服务 +services: + # 后端服务 + fastapi_elevue: + # 镜像名称 + image: fastapi_elevue + # 容器名称 + container_name: fastapi_elevue + # 构建镜像 + build: + context: ./ + dockerfile: Dockerfile + #build: . + # 将容器运行在特权模式下,意味着容器内的进程将具 + # 有访问宿主机的权限,包括文件系统、设备和系统功能等 + privileged: true + # 指定容器中运行的用户 + user: root + # 启动策略为始终重启 + restart: always + # 设置网络模式为host模式 + network_mode: host + # 端口映射 + ports: + - 8031:8031 + # 文件挂载 + volumes: + - ./uploads:/data/apps/uploads + # 执行命令 + command: python main.py runserver 0.0.0.0:8031 --noreload + # 日志配置 + logging: + driver: "json-file" + options: + max-size: "500m" + max-file: "10" diff --git a/extensions/__init__.py b/extensions/__init__.py new file mode 100644 index 0000000..1a9a2ef --- /dev/null +++ b/extensions/__init__.py @@ -0,0 +1,46 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 一米阳光团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +from extensions.sqlalchemy import get_db_session, commit_independently + + +def register_extends(app): + pass diff --git a/extensions/exception.py b/extensions/exception.py new file mode 100644 index 0000000..2d42dac --- /dev/null +++ b/extensions/exception.py @@ -0,0 +1,55 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 一米阳光团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +# 登录认证异常处理类 +class AuthorizationException(Exception): + def __init__(self, code: int, msg: str): + super().__init__(msg) + self.code = code + self.msg = msg + + +# 业务异常处理类 +class BusinessException(Exception): + def __init__(self, code: int = 1, msg: str = ""): + super().__init__(msg) + self.code = code + self.msg = msg diff --git a/extensions/sqlalchemy.py b/extensions/sqlalchemy.py new file mode 100644 index 0000000..b0f3613 --- /dev/null +++ b/extensions/sqlalchemy.py @@ -0,0 +1,111 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 一米阳光团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +from contextvars import ContextVar + +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from config import ( + SQLALCHEMY_MYSQL_URL, FASTAPI_DEBUG, + DB_POOL_SIZE, DB_MAX_OVERFLOW, DB_POOL_RECYCLE, + DB_POOL_TIMEOUT, DB_CONNECT_TIMEOUT, +) + +# 创建一个SQLAlchemy引擎 +engine = create_engine( + SQLALCHEMY_MYSQL_URL, + pool_size=DB_POOL_SIZE, + pool_recycle=DB_POOL_RECYCLE, + pool_timeout=DB_POOL_TIMEOUT, + max_overflow=DB_MAX_OVERFLOW, + connect_args={"connect_timeout": DB_CONNECT_TIMEOUT}, + pool_pre_ping=True, + pool_use_lifo=True, + echo_pool=FASTAPI_DEBUG, +) +# SessionLocal该类的每个实例将是一个数据库会话。该类本身还不是数据库会话。 +# 一旦我们创建了SessionLocal该类的实例,该实例将成为实际的数据库会话。 +# 我们SessionLocal将其命名为有别于Session我们从SQLAlchemy导入的名称。 +# 要创建SessionLocal类,请使用函数sessionmaker: +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# 请求级数据库 session 上下文变量 +_db_ctx: ContextVar = ContextVar('_db_ctx') + + +def get_db_session(): + """获取当前请求的数据库 session""" + return _db_ctx.get() + + +def commit_independently(obj): + """ + 使用独立session提交对象,不受请求事务影响 + + 日志等审计数据无论业务成功或失败都应持久化, + 因此需要绕过 AOP 事务中间件的 commit/rollback 机制。 + + 实现方式:基于现有引擎创建独立 session,将对象添加并提交, + 完成后关闭 session,不影响请求主 session。 + + Args: + obj: SQLAlchemy 模型实例(未添加到任何 session) + + Raises: + Exception: 数据库操作异常 + """ + independent_session = SessionLocal() + try: + independent_session.add(obj) + independent_session.commit() + except Exception: + independent_session.rollback() + raise + finally: + independent_session.close() + + +# 现在,我们将使用declarative_base()返回类的函数。 +# 稍后,我们将从该类继承以创建每个数据库模型或类(ORM模型) +# 返回一个类,后续作为数据库模型的基类(ORM模型) +Base = declarative_base() diff --git a/main.py b/main.py new file mode 100644 index 0000000..be1b60e --- /dev/null +++ b/main.py @@ -0,0 +1,51 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 一米阳光团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +from apps import create_app +from config import FASTAPI_HOST, FASTAPI_PORT + +app = create_app() + +if __name__ == '__main__': + import uvicorn + + # 使用uvicorn创建我们的服务 + uvicorn.run(app, host=FASTAPI_HOST, port=int(FASTAPI_PORT), reload=False) diff --git a/requirements - 副本.txt b/requirements - 副本.txt new file mode 100644 index 0000000..0ed2ad3 Binary files /dev/null and b/requirements - 副本.txt differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0bc1207 Binary files /dev/null and b/requirements.txt differ diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..f355f73 --- /dev/null +++ b/routers/__init__.py @@ -0,0 +1,17 @@ +from fastapi import FastAPI, Depends + +from apps.middleware.login_required import login_required +from apps.views import login as login_view +from routers.v1 import v1 + + +# 注册路由 +def register_router(app: FastAPI): + # 登录和验证码无需认证,直接挂载到 app + app.include_router(login_view.router, tags=["系统登录"]) + + # 需要认证的路由集合 + app.include_router( + v1, + dependencies=[Depends(login_required)], + ) diff --git a/routers/v1.py b/routers/v1.py new file mode 100644 index 0000000..8dd3280 --- /dev/null +++ b/routers/v1.py @@ -0,0 +1,100 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 一米阳光团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +from fastapi import APIRouter + +from apps.views import level, position, dept, role, menu, role_menu, user, city, notice, item, item_cate, link, \ + ad_sort, ad, member_level, member, dict as dict_view, dict_item as dict_item_view, config, config_item, index, upload, login_log, operation_log, job, job_log + +# 创建路由 +v1 = APIRouter() +# 系统主页 +v1.include_router(index.router, prefix="/index", tags=["系统主页"]) +# 上传文件 +v1.include_router(upload.router, prefix="/upload", tags=["上传文件"]) +# 职级管理 +v1.include_router(level.router, prefix="/level", tags=["职级管理"]) +# 岗位管理 +v1.include_router(position.router, prefix="/position", tags=["岗位管理"]) +# 部门管理 +v1.include_router(dept.router, prefix="/dept", tags=["部门管理"]) +# 角色管理 +v1.include_router(role.router, prefix="/role", tags=["角色管理"]) +# 角色菜单 +v1.include_router(role_menu.router, prefix="/rolemenu", tags=["角色菜单"]) +# 菜单管理 +v1.include_router(menu.router, prefix="/menu", tags=["菜单管理"]) +# 用户管理 +v1.include_router(user.router, prefix="/user", tags=["用户管理"]) +# 城市管理 +v1.include_router(city.router, prefix="/city", tags=["城市管理"]) +# 通知公告 +v1.include_router(notice.router, prefix="/notice", tags=["通知公告"]) +# 站点管理 +v1.include_router(item.router, prefix="/item", tags=["站点管理"]) +# 栏目管理 +v1.include_router(item_cate.router, prefix="/itemcate", tags=["栏目管理"]) +# 友链管理 +v1.include_router(link.router, prefix="/link", tags=["友链管理"]) +# 广告位管理 +v1.include_router(ad_sort.router, prefix="/adsort", tags=["广告位管理"]) +# 广告管理 +v1.include_router(ad.router, prefix="/ad", tags=["广告管理"]) +# 会员等级管理 +v1.include_router(member_level.router, prefix="/memberlevel", tags=["会员等级管理"]) +# 会员管理 +v1.include_router(member.router, prefix="/member", tags=["会员管理"]) +# 字典管理 +v1.include_router(dict_view.router, prefix="/dict", tags=["字典管理"]) +# 字典项管理 +v1.include_router(dict_item_view.router, prefix="/dictitem", tags=["字典项管理"]) +# 配置管理 +v1.include_router(config.router, prefix="/config", tags=["配置管理"]) +# 配置项管理 +v1.include_router(config_item.router, prefix="/configitem", tags=["配置项管理"]) +# 登录日志 +v1.include_router(login_log.router, prefix="/loginlog", tags=["登录日志"]) +# 操作日志 +v1.include_router(operation_log.router, prefix="/operationlog", tags=["操作日志"]) +# 定时任务 +v1.include_router(job.router, prefix="/job", tags=["定时任务"]) +# 定时任务日志 +v1.include_router(job_log.router, prefix="/joblog", tags=["定时任务日志"]) diff --git a/utils/.gitignore b/utils/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/utils/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file