zlwu 5 ماه پیش
والد
کامیت
52d892337e

+ 167 - 0
跟教务系统同步学生异动信息/api_service/README.md

@@ -0,0 +1,167 @@
+# 学生异动视图只读 API
+
+从 MySQL 视图 `vw_jw_student_change_export` 拉取数据,供教务或其它系统调用。
+
+- **Python**:开发与生产建议 **3.7.9**(与现网一致)。
+- **依赖**:见 `requirements.txt`(Flask 2.0.x + PyMySQL)。
+
+## 配置文件与环境对应关系
+
+| 本目录文件 | 后端配置来源 | Spring 环境 |
+|------------|----------------|-------------|
+| **`config.dev.ini`** | `backend/ruoyi-admin/src/main/resources/application-dev.yml`(`spring.datasource.dynamic.datasource.master`) | dev(测试/开发) |
+| **`config.prod.ini`** | `backend/ruoyi-admin/src/main/resources/application-prod.yml`(同上) | prod(生产) |
+
+主机、端口、库名、用户名、密码已与上述 YAML 中 **master** 数据源保持一致;后端配置变更时请同步修改对应 INI。
+
+## 安装
+
+### 开发机(推荐虚拟环境)
+
+```bash
+cd 跟教务系统同步学生异动信息/api_service
+python3.7 -m venv .venv
+source .venv/bin/activate   # Windows: .venv\Scripts\activate
+pip install -r requirements.txt
+```
+
+### Linux 生产环境:`pip3:未找到命令` / `No module named pymysql`
+
+很多最小化系统只有 `python3`,没有独立的 **`pip3`** 命令。请用 **当前解释器自带的 pip 模块** 安装依赖,例如:
+
+```bash
+cd /path/to/api_service
+
+# 1)优先尝试(与 python3 一一对应)
+python3 -m pip install -r requirements.txt
+
+# 若 `python3 -m pip` 报 No module named pip,先装系统级 pip(需 root):
+#   CentOS 7 / 部分 RHEL:
+#     yum install -y python3-pip
+#   CentOS 8+ / RHEL 8+ / Fedora:
+#     dnf install -y python3-pip
+# 装完再执行:
+python3 -m pip install -r requirements.txt
+
+# 若无 yum/dnf 或仓库无 python3-pip,可用官方引导脚本(任选其一 Python 版本需为 3.7+):
+#   curl -sS https://bootstrap.pypa.io/pip/3.7/get-pip.py -o get-pip.py
+#   python3 get-pip.py
+#   python3 -m pip install -r requirements.txt
+# (3.7 专用 get-pip 见:https://bootstrap.pypa.io/pip/3.7/get-pip.py )
+```
+
+确认:
+
+```bash
+python3 -c "import pymysql, flask; print('ok')"
+```
+
+启动时请用 **`python3 app.py`**(或与安装依赖时相同的 `python3`),避免混用 `python` 指向旧版 2.x。
+
+## 选用配置并启动
+
+**测试/开发**(与 dev 库一致):
+
+```bash
+cp config.dev.ini config.ini
+python app.py
+```
+
+或直接:
+
+```bash
+export JW_CX_CONFIG="$(pwd)/config.dev.ini"
+python app.py
+```
+
+**生产**:
+
+```bash
+export JW_CX_CONFIG=/opt/jw-cx/config.prod.ini
+python app.py
+```
+
+### 配置文件说明
+
+| 环境变量 | 说明 |
+|----------|------|
+| `JW_CX_CONFIG` | 配置文件路径;不设则使用当前工作目录下的 `config.ini`(相对路径相对于**进程启动时的工作目录**) |
+
+模板见 **`config.ini.example`**。**`config.ini`** 已在 `.gitignore` 中忽略,避免把本地覆盖提交上去。
+
+### INI 节说明
+
+**`[database]`**:`host`、`port`、`user`、`password`、`database`  
+**`[api]`**:
+
+| 键 | 说明 |
+|----|------|
+| `api_key` | 与请求头 `X-Jw-Cx-Key` 一致 |
+| `bind_host` / `bind_port` | HTTP 监听(仅直接运行 `python app.py` 时生效) |
+| `page_size` | **默认每页条数**;请求可带 `page_size` Query 覆盖,程序内限制 1~5000 |
+| `recent_days` | **默认近 N 天**;请求可带 `recent_days` / `days` 覆盖,程序内限制 1~3660 |
+
+## 密钥校验
+
+所有 **`/api/v1/student-changes`** 请求必须在 HTTP Header 中携带:
+
+```http
+X-Jw-Cx-Key: <与配置文件中 [api] api_key 相同,默认 JWCX001>
+```
+
+**`/health`** 不做密钥校验。
+
+## 生产进程(gunicorn)
+
+```bash
+pip install 'gunicorn>=20.1.0,<21'
+export JW_CX_CONFIG=/path/to/config.prod.ini
+gunicorn -w 2 -b 0.0.0.0:5050 app:app
+```
+
+监听地址/端口以 gunicorn 的 `-b` 为准;`bind_port` 仅对直接运行 `python app.py` 有效。
+
+## 接口说明
+
+### GET `/health`
+
+返回 `{"status":"ok"}`。
+
+### GET `/api/v1/student-changes`
+
+**数据范围**:仅 **`提交时间` 在最近 N 个自然日内**的记录;N 由 **`recent_days`**(或 `days`)指定,**未传则使用配置** `[api] recent_days`(默认 30)。  
+**分页**:每页条数 **`page_size`** 可传 Query 覆盖,**未传则使用** `[api] page_size`(默认 500)。
+
+| Query | 说明 | 未传时的默认值 |
+|--------|------|------------------|
+| `page` | 页码,从 1 开始 | `1` |
+| `page_size` | 每页条数,有效范围 1~5000 | `[api] page_size` |
+| `recent_days` | 查询近 N 天(与视图列「提交时间」比较),有效范围 1~3660 | `[api] recent_days` |
+| `days` | 与 `recent_days` 同义,二选一即可 | (同上) |
+
+成功响应中除 `data`、`page`、`page_size`、`total` 外,还包括:
+
+| JSON 字段 | 说明 |
+|-----------|------|
+| `filter_recent_days` | **本次请求实际生效**的近 N 天(含 URL 覆盖后的值) |
+| `filter_by_column` | 固定为 `提交时间`(筛选字段名) |
+
+**未授权**(HTTP 401):未带或错误的 `X-Jw-Cx-Key`。
+
+## 调用示例
+
+```bash
+# 使用配置中的 page_size、recent_days
+curl -s -H "X-Jw-Cx-Key: JWCX001" \
+  "http://127.0.0.1:5050/api/v1/student-changes?page=1"
+
+# 指定每页 50 条、近 7 天
+curl -s -H "X-Jw-Cx-Key: JWCX001" \
+  "http://127.0.0.1:5050/api/v1/student-changes?page=1&page_size=50&recent_days=7"
+```
+
+## 安全说明
+
+1. `config.dev.ini` / `config.prod.ini` 含数据库凭据,与后端 YAML 同源;请按公司规范控制仓库可见性与部署权限。
+2. 建议数据库侧使用**仅对视图** `vw_jw_student_change_export` 有 `SELECT` 的专用账号,与后端 `root` 分离。
+3. 生产将 **`api_key`** 换为强随机串,并走 HTTPS;必要时加网络白名单。

+ 270 - 0
跟教务系统同步学生异动信息/api_service/app.py

@@ -0,0 +1,270 @@
+# -*- coding: utf-8 -*-
+"""
+教务同步:学生异动视图只读 API(Python 3.7+)
+
+配置:默认读取与本文件同目录下的 config.ini(INI 格式),路径可用环境变量 JW_CX_CONFIG 覆盖。
+校验:请求头 X-Jw-Cx-Key 须与配置文件 [api] api_key 一致。
+"""
+import hmac
+import os
+import sys
+from datetime import date, datetime
+from decimal import Decimal
+
+import configparser
+
+import pymysql
+from flask import Flask, jsonify, request
+from pymysql.cursors import DictCursor
+
+# 与调用方约定的 Header 名(勿随意改动,已写入 README)
+HEADER_API_KEY = "X-Jw-Cx-Key"
+
+VIEW_NAME = "vw_jw_student_change_export"
+
+# 视图列名(与 vw_jw_student_change_export 一致;筛选近 N 天时使用)
+COL_SUBMIT_DATE = "提交时间"
+
+_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+_DEFAULT_CONFIG_PATH = os.path.join(_BASE_DIR, "config.ini")
+
+app = Flask(__name__)
+
+_config_parser = None
+
+
+def _config_path():
+    return os.environ.get("JW_CX_CONFIG", _DEFAULT_CONFIG_PATH)
+
+
+def _load_config():
+    """加载并缓存 INI 配置;缺失文件时给出明确提示。"""
+    global _config_parser
+    if _config_parser is not None:
+        return _config_parser
+
+    path = _config_path()
+    if not os.path.isfile(path):
+        sys.stderr.write(
+            "错误:未找到配置文件 %s\n"
+            "请复制 config.ini.example 为 config.ini 并填写数据库等信息,"
+            "或设置环境变量 JW_CX_CONFIG 指向您的配置文件路径。\n" % (path,)
+        )
+        sys.exit(1)
+
+    parser = configparser.ConfigParser()
+    # Python 3.2+:read 支持 encoding
+    read_ok = parser.read(path, encoding="utf-8")
+    if not read_ok:
+        sys.stderr.write("错误:无法读取配置文件 %s\n" % (path,))
+        sys.exit(1)
+
+    _config_parser = parser
+    return _config_parser
+
+
+def _get(section, option, fallback=""):
+    parser = _load_config()
+    if parser.has_option(section, option):
+        return parser.get(section, option).strip()
+    return fallback
+
+
+def _get_int(section, option, fallback):
+    raw = _get(section, option, str(fallback))
+    try:
+        return int(raw)
+    except ValueError:
+        return int(fallback)
+
+
+def configured_api_key():
+    return _get("api", "api_key", "JWCX001")
+
+
+def db_config():
+    return {
+        "host": _get("database", "host", "127.0.0.1"),
+        "port": _get_int("database", "port", 3306),
+        "user": _get("database", "user", "jw_cx"),
+        "password": _get("database", "password", ""),
+        "database": _get("database", "database", "hzdyjs_xg"),
+    }
+
+
+def server_bind():
+    return {
+        "host": _get("api", "bind_host", "0.0.0.0"),
+        "port": _get_int("api", "bind_port", 5050),
+    }
+
+
+def _api_defaults_from_config():
+    """从 [api] 读取 page_size、recent_days 默认值(INI 见 config*.ini)。"""
+    page_size = _get_int("api", "page_size", 500)
+    recent_days = _get_int("api", "recent_days", 30)
+    if page_size < 1:
+        page_size = 1
+    if page_size > 5000:
+        page_size = 5000
+    if recent_days < 1:
+        recent_days = 1
+    if recent_days > 3660:
+        recent_days = 3660
+    return page_size, recent_days
+
+
+def _clamp_int(value, lo, hi):
+    v = int(value)
+    if v < lo:
+        return lo
+    if v > hi:
+        return hi
+    return v
+
+
+def _resolve_query_paging():
+    """
+    合并 URL 参数与配置文件:
+    - page_size:Query `page_size`,未传则用 [api] page_size
+    - recent_days:Query `recent_days` 或 `days`(别名),未传则用 [api] recent_days
+    范围:page_size 1~5000,recent_days 1~3660
+    """
+    cfg_ps, cfg_rd = _api_defaults_from_config()
+
+    raw_ps = request.args.get("page_size")
+    if raw_ps is not None and str(raw_ps).strip() != "":
+        try:
+            page_size = _clamp_int(raw_ps, 1, 5000)
+        except (ValueError, TypeError):
+            return None, None, "page_size 必须为整数"
+    else:
+        page_size = cfg_ps
+
+    raw_rd = request.args.get("recent_days")
+    if raw_rd is None or str(raw_rd).strip() == "":
+        raw_rd = request.args.get("days")
+    if raw_rd is not None and str(raw_rd).strip() != "":
+        try:
+            recent_days = _clamp_int(raw_rd, 1, 3660)
+        except (ValueError, TypeError):
+            return None, None, "recent_days / days 必须为整数"
+    else:
+        recent_days = cfg_rd
+
+    return page_size, recent_days, None
+
+
+def _check_api_key():
+    sent = request.headers.get(HEADER_API_KEY, "")
+    expected = configured_api_key()
+    if not expected:
+        return False
+    try:
+        return hmac.compare_digest(sent.encode("utf-8"), expected.encode("utf-8"))
+    except Exception:
+        return False
+
+
+def _serialize_value(value):
+    if value is None:
+        return None
+    if isinstance(value, datetime):
+        return value.strftime("%Y-%m-%d %H:%M:%S")
+    if isinstance(value, date):
+        return value.strftime("%Y-%m-%d")
+    if isinstance(value, Decimal):
+        return str(value)
+    return value
+
+
+def _row_to_dict(row):
+    return {k: _serialize_value(v) for k, v in row.items()}
+
+
+def _db_connect():
+    cfg = db_config()
+    return pymysql.connect(
+        host=cfg["host"],
+        port=cfg["port"],
+        user=cfg["user"],
+        password=cfg["password"],
+        database=cfg["database"],
+        charset="utf8mb4",
+        cursorclass=DictCursor,
+        autocommit=True,
+    )
+
+
+@app.route("/health", methods=["GET"])
+def health():
+    """存活探测,不做密钥校验,便于负载均衡 / 运维。"""
+    return jsonify({"status": "ok"})
+
+
+@app.route("/api/v1/student-changes", methods=["GET"])
+def list_student_changes():
+    if not _check_api_key():
+        return jsonify({"code": 401, "msg": "无效的密钥或未提供请求头 " + HEADER_API_KEY}), 401
+
+    try:
+        page = int(request.args.get("page", "1"))
+    except ValueError:
+        return jsonify({"code": 400, "msg": "page 必须为整数"}), 400
+
+    if page < 1:
+        page = 1
+
+    page_size, recent_days, param_err = _resolve_query_paging()
+    if param_err:
+        return jsonify({"code": 400, "msg": param_err}), 400
+
+    offset = (page - 1) * page_size
+
+    # 近 recent_days 天:按视图中的「提交时间」筛选(登记/提交日期)
+    where_recent = (
+        "WHERE `{col}` >= DATE_SUB(CURDATE(), INTERVAL {days} DAY)".format(
+            col=COL_SUBMIT_DATE, days=recent_days
+        )
+    )
+    sql_count = "SELECT COUNT(*) AS cnt FROM `{view}` {where_clause}".format(
+        view=VIEW_NAME, where_clause=where_recent
+    )
+    sql_list = (
+        "SELECT * FROM `{view}` {where_clause} "
+        "ORDER BY `来源记录id` ASC LIMIT %s OFFSET %s"
+    ).format(view=VIEW_NAME, where_clause=where_recent)
+
+    conn = None
+    try:
+        conn = _db_connect()
+        with conn.cursor() as cur:
+            cur.execute(sql_count)
+            total = int(cur.fetchone()["cnt"])
+            cur.execute(sql_list, (page_size, offset))
+            rows = cur.fetchall()
+    except pymysql.Error as e:
+        return jsonify({"code": 500, "msg": "数据库查询失败", "detail": str(e)}), 500
+    finally:
+        if conn is not None:
+            conn.close()
+
+    data = [_row_to_dict(r) for r in rows]
+    return jsonify(
+        {
+            "code": 200,
+            "msg": "success",
+            "data": data,
+            "page": page,
+            "page_size": page_size,
+            "total": total,
+            "filter_recent_days": recent_days,
+            "filter_by_column": COL_SUBMIT_DATE,
+        }
+    )
+
+
+if __name__ == "__main__":
+    _load_config()
+    bind = server_bind()
+    app.run(host=bind["host"], port=bind["port"], threaded=True)

+ 22 - 0
跟教务系统同步学生异动信息/api_service/config.dev.ini

@@ -0,0 +1,22 @@
+# 测试 / 开发环境(与后端 spring.profiles.active=dev 对应)
+# 数据源来源:backend/ruoyi-admin/src/main/resources/application-dev.yml
+#   spring.datasource.dynamic.datasource.master.url / username / password
+# JDBC: jdbc:mysql://47.99.164.66:3306/hzdyjs_xg?...
+#
+# 使用方式:复制为 config.ini,或 JW_CX_CONFIG=.../config.dev.ini python app.py
+#
+# 说明:与后端一致使用 root;若仅开放视图权限,可改为仅 SELECT 视图的用户(如 jw_cx)。
+
+[database]
+host = 47.99.164.66
+port = 3306
+user = root
+password = hn02le.34lkdLKD
+database = hzdyjs_xg
+
+[api]
+api_key = JWCX001
+bind_host = 0.0.0.0
+bind_port = 5050
+page_size = 500
+recent_days = 30

+ 23 - 0
跟教务系统同步学生异动信息/api_service/config.ini.example

@@ -0,0 +1,23 @@
+# 模板:请复制其一并重命名为 config.ini(或在启动时通过 JW_CX_CONFIG 指定)
+#
+# 已与本项目后端数据源对齐的现成文件(可直接复制使用):
+#   config.dev.ini  ← 对应 application-dev.yml(测试/开发)
+#   config.prod.ini ← 对应 application-prod.yml(生产)
+#
+#  cp config.dev.ini config.ini
+
+[database]
+host = 127.0.0.1
+port = 3306
+user = root
+password =
+database = hzdyjs_xg
+
+[api]
+api_key = JWCX001
+bind_host = 0.0.0.0
+bind_port = 5050
+# 列表接口:默认每页条数;请求可带 page_size 覆盖,范围 1~5000
+page_size = 500
+# 列表接口:默认近 N 天;请求可带 recent_days 或 days 覆盖,范围 1~3660
+recent_days = 30

+ 23 - 0
跟教务系统同步学生异动信息/api_service/config.prod.ini

@@ -0,0 +1,23 @@
+# 生产环境(与后端 spring.profiles.active=prod 对应)
+# 数据源来源:backend/ruoyi-admin/src/main/resources/application-prod.yml
+#   spring.datasource.dynamic.datasource.master.url / username / password
+# JDBC: jdbc:mysql://172.16.0.67:3306/hzdyjs_xg?...
+#
+# 使用方式:部署时复制为 config.ini,或
+#   export JW_CX_CONFIG=/path/to/config.prod.ini
+#
+# 说明:与后端主库一致;建议生产单独使用仅 vw_jw_student_change_export 只读账号。
+
+[database]
+host = 172.16.0.67
+port = 3306
+user = root
+password = Hzdyjs@Root@123456
+database = hzdyjs_xg
+
+[api]
+api_key = JWCX001
+bind_host = 0.0.0.0
+bind_port = 5050
+page_size = 500
+recent_days = 30

+ 3 - 0
跟教务系统同步学生异动信息/api_service/requirements.txt

@@ -0,0 +1,3 @@
+# 生产环境 Python 3.7.9 兼容
+Flask>=2.0.2,<2.3
+PyMySQL>=1.0.2,<2.0