From 1e71c164afc7dc5a09f1b7fd27a53fe6a7341a3a Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Tue, 20 Oct 2026 07:25:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(mp):=20=E6=8E=A5=E9=80=9A=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E7=99=BB=E5=BD=95=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E7=94=9F=E4=BA=A7=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端落地页调用 wx.login → /mp/login,保存 token,并在每次带 scene 进入时刷新登录(F-130 前端断点) - 后端 /mp/login 的名册/任务查询放入 TenantScope,修复带 taskId 的 scene 必然 500(F-130 后端断点) - 小程序生产 API 改为 VITE_MP_API_BASE(.env.production),不再写死 localhost - 生产配置补全:七牛目录 shenxue、连接池/健康检查/优雅停机等;学生端只发小程序,移除 H5 段 - PC 生产 API 改到 admin.shenxue.jxit.com;新增 deploy/prod.env.example 并忽略 prod.env - ⚠️ application-prod.yml 含生产凭据(按部署要求直接写入),仓库访问范围需收紧 --- .gitignore | 4 + .../module/mp/service/MpH5AuthService.java | 2 +- .../module/mp/service/MpLoginService.java | 36 ++- .../src/main/resources/application-prod.yml | 235 +++++++++++++----- deploy/prod.env.example | 74 ++++++ frontend-mp/.env.production | 9 + frontend-mp/src/config/index.ts | 13 +- frontend-mp/src/pages/index/index.vue | 75 +++++- frontend-mp/src/utils/auth.ts | 142 +++++++++++ frontend-pc/.env.production | 2 +- 10 files changed, 505 insertions(+), 87 deletions(-) create mode 100644 deploy/prod.env.example create mode 100644 frontend-mp/.env.production create mode 100644 frontend-mp/src/utils/auth.ts diff --git a/.gitignore b/.gitignore index 93bf08f..62f0009 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,7 @@ backend/data/ application-local.yml # 本地私有配置(云存储密钥等)—— 只提交 .example 模板 xt-local.yml + +# 生产环境变量(含 JWT / AES / RSA / 七牛真凭据)—— 只提交 .example 模板 +deploy/prod.env +deploy/prod-rsa.pem diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpH5AuthService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpH5AuthService.java index 6ed71f2..3217430 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpH5AuthService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpH5AuthService.java @@ -167,7 +167,7 @@ public class MpH5AuthService { if (nz(h5Mapper.countUserRole(tenantId, created.getId(), roleId)) == 0) { h5Mapper.insertUserRole(tenantId, created.getId(), roleId); } - log.info("H5 首次登录,已建学生账号:tenant={} uid={} openid={}***", + log.info("微信首次登录(H5 网页授权 / 小程序共用),已建学生账号:tenant={} uid={} openid={}***", tenantId, created.getId(), openId.substring(0, Math.min(6, openId.length()))); return created; } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpLoginService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpLoginService.java index 6ae11c4..3ce8f19 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpLoginService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpLoginService.java @@ -14,6 +14,7 @@ import cn.tianbaotong.fill.module.task.mapper.TaskMapper; import cn.tianbaotong.fill.module.user.entity.SysUser; import cn.tianbaotong.fill.security.JwtService; import cn.tianbaotong.fill.support.SensitiveCodec; +import cn.tianbaotong.fill.support.TenantScope; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -31,6 +32,7 @@ import java.time.Duration; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** * 小程序端登录与手机号绑定(《03-接口设计》§5.3)。 @@ -123,17 +125,31 @@ public class MpLoginService { // 扫的是"任务码"时,顺带把他在这份任务里的名册绑定带回去 —— // 前端据此决定进"填报页"还是"先核验身份",省掉一次请求 + // + // ⚠️ 这段查询**必须**放进租户上下文:roster/task 都是租户表, + // 而登录是匿名请求(还没有登录态)。不做 TenantScope 的话, + // 租户拦截器会因为"拿不到 tenantId"直接拒绝 —— + // 症状是 /mp/login 带 taskId 的 scene 一律 500,而只带 t{tid} 的码却正常 + // (那条路径根本不走到这里),很难从现象定位到原因。 if (s.taskId() != null) { - CollectRoster r = rosterMapper.selectOne(Wrappers.lambdaQuery() - .eq(CollectRoster::getTaskId, s.taskId()) - .eq(CollectRoster::getBindUserId, user.getId()) - .last("limit 1")); - if (r != null) { - CollectTask t = taskMapper.selectById(s.taskId()); - Map br = new LinkedHashMap<>(); - br.put("taskId", s.taskId()); - br.put("taskName", t == null ? null : t.getTaskName()); - br.put("rosterId", r.getId()); + Map br = TenantScope.callAs( + s.tenantId(), user.getId(), Set.of("STUDENT"), () -> { + CollectRoster r = rosterMapper.selectOne( + Wrappers.lambdaQuery() + .eq(CollectRoster::getTaskId, s.taskId()) + .eq(CollectRoster::getBindUserId, user.getId()) + .last("limit 1")); + if (r == null) { + return null; + } + CollectTask t = taskMapper.selectById(s.taskId()); + Map m = new LinkedHashMap<>(); + m.put("taskId", s.taskId()); + m.put("taskName", t == null ? null : t.getTaskName()); + m.put("rosterId", r.getId()); + return m; + }); + if (br != null) { vo.setBoundRoster(br); } } diff --git a/backend/xt-server/src/main/resources/application-prod.yml b/backend/xt-server/src/main/resources/application-prod.yml index 4652e95..56eb874 100644 --- a/backend/xt-server/src/main/resources/application-prod.yml +++ b/backend/xt-server/src/main/resources/application-prod.yml @@ -1,27 +1,28 @@ # ═══════════════════════════════════════════════════════════ # 生产环境(prod) # -# ⚠️⚠️ 这个文件里**一个密钥都没有,也永远不要加**。 -# 全部由环境变量提供;缺失时 **ProdGuard 会拒绝启动** -# (见 config/ProdGuard.java —— 那 6 条都是"配错了也能启动、 -# 而且运行时完全不报错"的配置)。 +# ⚠️⚠️ 本文件按部署要求**直接写入了**数据库、Redis、七牛与 +# 应用密钥(JWT / AES / HMAC / RSA)—— 它属于**敏感文件**: +# · 仓库可见范围必须收紧(能读它 = 能伪造 token、能解证件号) +# · 不要发到公开渠道,也不要复制到别处 +# · 轮换密钥时优先用同名环境变量覆盖(环境变量优先级高于本文件) # -# ── 必需的环境变量 ────────────────────────────────────── -# 数据库 PG_HOST PG_PORT PG_DB PG_USER PG_PASSWORD -# 缓存 REDIS_HOST REDIS_PORT REDIS_PASSWORD -# 密钥 XT_JWT_SECRET HS512,≥ 64 字节随机值 -# XT_AES_KEY AES-256,base64,**用于证件号加密** -# XT_HMAC_KEY HMAC,用于开放接口签名 -# XT_RSA_PRIVATE_KEY PKCS#8 单行 base64(登录密码解密) -# 存储 XT_STORAGE_PROVIDER=qiniu -# QINIU_ACCESS_KEY QINIU_SECRET_KEY QINIU_BUCKET QINIU_DOMAIN -# 微信 WX_MP_APP_ID WX_MP_APP_SECRET (小程序) -# WX_H5_APP_ID WX_H5_APP_SECRET (公众号网页授权) -# WX_H5_CALLBACK WX_H5_BASE_URL (必须是 https 正式域名) -# 其他 H5_STATIC_DIR(H5 构建产物目录) +# ── 仍然建议用环境变量提供的项 ───────────────────────── +# 微信小程序 WX_MP_APP_ID WX_MP_APP_SECRET WX_MP_PUBLIC_BASE_URL +# WX_MP_CALLBACK_TOKEN +# 其他 XT_LOG_DIR XT_UPLOAD_DIR XT_SEED_ENABLED +# XT_TENANT_DOMAIN_ENABLED XT_TENANT_DOMAIN_BASE +# +# ⚠️ 学生端**只发布微信小程序,不部署 H5**: +# 本文件没有 fill.mp-h5 段。后端 /api/v1/mp/h5/** 接口仍在, +# 未配置时会明确报错,但生产不提供 H5 页面与公众号网页授权。 # # ⚠️ 密钥怎么生成、怎么轮换、轮换时要注意什么,见 # docs/09-部署与运维.md §3「密钥与凭据」。 +# **XT_AES_KEY 一旦加密过证件号就不能直接换**(旧密文解不开且不报错)。 +# +# ⚠️ 生产的工作目录里不要放 application-local.yml: +# 基础配置里有 spring.profiles.include: local,它会被加载并可能覆盖本文件。 # ═══════════════════════════════════════════════════════════ spring: @@ -29,17 +30,36 @@ spring: url: jdbc:postgresql://1Panel-postgresql-v7DP:5432/shenxue?stringtype=unspecified username: shenxue password: RC6Bhnmbz8mk4p4z + driver-class-name: org.postgresql.Driver hikari: + pool-name: xt-hikari-prod # 生产把池子放大一些:导出打包是长事务,会占住连接 minimum-idle: 10 maximum-pool-size: 40 + connection-timeout: 10000 + idle-timeout: 600000 + max-lifetime: 1700000 + # 慢 SQL / 连接未归还从应用侧记一份,方便和 PG 的 + # log_min_duration_statement 对照 + leak-detection-threshold: 20000 data: redis: host: 1Panel-redis-mIkk port: 6379 password: redis_GWnh6E database: 0 + timeout: 3s + lettuce: + pool: + max-active: 32 + max-idle: 16 + min-idle: 4 + max-wait: 3s flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: false + table: flyway_schema_history # ⚠️⚠️ 生产**禁止 clean**。它是"清空整个库再重建", # 在生产上执行等于把所有学校的填报数据删掉。 # 流水线里也不要用 flyway clean —— 见部署文档的回滚一节。 @@ -47,103 +67,194 @@ spring: # 迁移文件发布后不许改(改了校验失败)。这是保护:它保证 # "生产跑的迁移"与"仓库里的迁移"逐字节一致 validate-on-migrate: true - # ⚠️ 这里**没有** static-locations:H5 由 nginx/网关托管,不落在后端 - # (2026-10 架构调整,见 application.yml 的 spring.web.resources.add-mappings)。 - # 发布时要注意的是**换了个东西发**:H5 产物由部署流程放到 nginx 的站点目录, - # 仍然要"和后端一起发" —— 前后端不同步还是会出现"页面调用了还不存在的接口"。 - # - # 历史(留着是有用的):它曾经是 `file:/opt/xt/h5/`,由本服务直接对外发 H5。 + jackson: + time-zone: Asia/Shanghai + default-property-inclusion: always + deserialization: + fail-on-unknown-properties: false + lifecycle: + # 配合 server.shutdown=graceful:等在途请求处理完再退出 + timeout-per-shutdown-phase: 30s + servlet: + multipart: + max-file-size: 50MB + max-request-size: 60MB + web: + resources: + # ⚠️ 后端是**纯 API 服务**:PC 管理端与学生端(微信小程序)都不由后端托管, + # 页面由 nginx/网关/微信客户端提供(见 application.yml 的 add-mappings 注释)。 + # 发布时仍然要"和后端一起发" —— 前后端不同步会出现"页面调用了还不存在的接口"。 + add-mappings: false server: + port: 8080 + # 优雅停机。没有它,spring.lifecycle.timeout-per-shutdown-phase 不生效 + shutdown: graceful # 生产在反向代理后面:让 Spring 认 X-Forwarded-* , # 否则生成的回调地址会是 http://内网IP:8080 forward-headers-strategy: framework + compression: + enabled: true + mime-types: application/json,text/html,text/css,application/javascript + min-response-size: 2048 + error: + # 不要把异常栈与内部错误信息回给调用方 + include-stacktrace: never + include-message: never tomcat: # 上传是长连接,生产放宽(学生可能从聊天记录里选一个 40MB 的文件) connection-timeout: 120s + max-swallow-size: 100MB threads: max: 200 min-spare: 20 +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + # 生产环境关掉 SQL 日志 + log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl + global-config: + banner: false + db-config: + # 软删除字段名,客户明确要求就叫 deleted + logic-delete-field: deleted + logic-delete-value: 1 + logic-not-delete-value: 0 + id-type: auto + logging: level: root: warn cn.tianbaotong.fill: info + # 迁移记录值得留在日志里(root=warn 会把它压掉) + org.flywaydb: info file: # ⚠️ 日志按天滚动并保留 30 天:审计要能回答"上周三谁看过那份证件号" - name: ${XT_LOG_DIR:/var/log/xt}/xt-server.log + name: "${XT_LOG_DIR:/var/log/xt}/xt-server.log" logback: rollingpolicy: max-history: 30 max-file-size: 200MB total-size-cap: 20GB +management: + endpoints: + web: + exposure: + # 生产只暴露健康检查(给负载均衡探活:/actuator/health) + include: health + endpoint: + health: + show-details: never + # 同时提供 /actuator/health/liveness 与 /actuator/health/readiness + probes: + enabled: true + health: + # 探活会实际探测 PG 与 Redis(依赖挂了不能还报 UP) + db: + enabled: true + redis: + enabled: true + # 生产关掉接口文档:它会把全部接口与字段暴露给公网。 -# ProdGuard 会检查这一条 —— 开着就拒绝启动。 +# ProdEnvCheck 会检查这一条 —— 开着就拒绝启动。 springdoc: api-docs: enabled: false swagger-ui: enabled: false -management: - endpoints: - web: - exposure: - # 生产只暴露健康检查(给负载均衡探活) - include: health - endpoint: - health: - show-details: never - +# ── 本项目业务配置 ──────────────────────────── fill: jwt: - secret: ${XT_JWT_SECRET} + issuer: tianbaotong-fill + # HS512 签名密钥(≥ 64 字节随机值)。环境变量优先,便于轮换 + secret: "${XT_JWT_SECRET:2gtJ3sKcegiZj8SbFuLOPRA8jF6tOwLwV16QzDtLZfaLIKEq0ZHEdneJjg8EDHUJ+CH7coufso0+2qD8dkxoKQ==}" + # PC 8 小时 / 小程序 7 天(《03-接口设计》§1.2) + pc-expire-seconds: 28800 + mp-expire-seconds: 604800 crypto: - aes-key: ${XT_AES_KEY} - hmac-key: ${XT_HMAC_KEY} + # AES-256(base64,32 字节),**用于证件号加密** + aes-key: "${XT_AES_KEY:N645XQtx+BDMlXTGe8YkJxUvesj9BYUPNQXLjQUiC/Q=}" + # HMAC,用于开放接口签名与证件号去重 hash + hmac-key: "${XT_HMAC_KEY:1eTpdihb/nZOh2PCpTE5o9SrLr9oGS7EqtbUM/3DNFe98CHxauViRL6beXQB8xci}" rsa: - private-key: ${XT_RSA_PRIVATE_KEY} + # PKCS#8 私钥(单行 Base64,无 PEM 头尾),用于登录密码解密。 + # 公钥由 GET /api/v1/auth/public-key 动态下发,轮换后前端无需重新构建。 + private-key: "${XT_RSA_PRIVATE_KEY:MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDnWFBpGQU+ZaSBBe8XWSbSgwlOn5fOr+LH2I4lHtgoOWECKZLL8Ltf/cdRL0cc7ZkMEyE2xvidW+G1ADEGKyy/5LIWZ/+37KfM25JHPhE3v3+K9j05mPG5Yq7Qwdaf9OKkwmL/qc7fPUVyzw/uNL8+oU7JP6Vw1weIxXLjGUHwmTcwGjEXAMiu39xnhxXtUa7oywa2XnGAFZm+bt/YoLFbI04SQ9/o5oFnr7KAUBPW3V8kVejuMMQK/qCy06xhzDBJUw+jYwM3Tu9otlPykZXiVrDC7psImoaFIbvOMRh1PILOa70eHgr86++ZKyyiGqFl5bQ/5jURKfKxHrw7BagbAgMBAAECggEAGj0viEQQuVuGQCYKpf90F530y8iZIMBdPI/Iy4Kx6e9cG9vYQlj6/5pakCWIb3UEDf+2HYQ4Gtea2XsnPvey+yQhY3OHPaFSBOUF3x5gjeNNQCxz6Zj5wwOOKATxkVyRP4ur9oBtg29ryuebpcewxes+HYr3pOfr+wIeE44//DLv/PfGfSnkb4n+eILrm6H4qWfTbZnBwUv1P6F4YJvknuVytkCLtRiZRcmZ/BXD1Uldcb9zQbeGylvjqPTO1wo1Rdq8IhC9ggfJIDMznRb7CMkEN5nE1W8VZgk5adJBoHGXFukQyq7ne5p3HODPrySn5TYBoyH2lo4bDpmIkLYcAQKBgQD4t/gwRxDiWV4R06Vc3kdQ5/C0A5221m19b5d2kGld+W48URbZsZbkGjYQ5eQ+5hDzUnP29kToQUzWjJFP89G1xqep93OzQdVMmTtKakuA+3T+LudvzNmwB/nT+4StwdXQdgImCqkQfVHmBVcmv0pyJP+yHCIT+X3vv33YxNF8AQKBgQDuHiMczAswsV4E4QmPdXgA10udOr1OrGv582adDHzIpAUExPvv1iUBQ3r1lcTYbEzUUbGl+d5xnkmxgc1ulzC0lgNgCrIcCWIJ/nCo8L09lvA4Q/R239i70QldU2Hd8UEL7hzyqr8DeEjyOIwHfqkpl7nGbVI+ukfSLNbv0T2UGwKBgQDssh+WyjruPkBA1Ns9SygxfdnbZsmT8/LU+fr50asEzNi0vDLzz4ca4zrqz+1sUoR1G9XShEHFikd2suWjYhUEWd676QuJ36//KqB7TDc4s5V+ROLpyditJpoBvA5jUeGKdpPK4kBMugbT0edDr65IfJ9+QDnKKphILkO0pHH8AQKBgAqLPxbJWj1/vSAOLvQxSW0QG4MM1NElBWr+Ai82HFhuGuLJLjbCjsTtjgZN6XeD5CsKqwkkdVsmnTDFBKm+/CMi+WdJUfW33KWApNB2SIrvohd7utmAtAItEXMWfCDty+mRU6rTML4gl2pSYrD6RNdYpptIv/mTGhcOcT4RT969AoGBAIZ6boFBwyt3zRD71SMHonujfU3ldeodAtNrpXovAHs3iwNpOUvjYSOAcUSTKB+FcKcR/xk5L9PlskmumNP/DdUXgPyPocjITdSwPT5JCXffgw3p50o+gxvw52EkPPHO9M9gXFZRCE04KF0ToQoYkX267ct/erlddBrbzsVJLCHZ}" security: + # 登录失败 5 次锁 15 分钟;连续失败 2 次后要求图形验证码(§1.6) login-max-fail: 5 login-lock-minutes: 15 captcha-after-fails: 2 + upload: + # 单文件上限 50MB(PRD §10.1) + max-file-size-mb: 50 + # 本地落盘目录。七牛只存学生材料;导出包等临时文件仍然写本地盘, + # 所以这个目录在生产上必须可写、且不要放在会被清理的 /tmp 下。 + dir: "${XT_UPLOAD_DIR:./data/uploads}" + storage: - # 生产必须 qiniu(local 在多副本下必然丢文件,ProdGuard 会拦) + # 生产必须 qiniu(local 在多副本下必然丢文件,ProdEnvCheck 会拦) provider: qiniu - prefix: xiaotutongxue - keep-raw: ${XT_STORAGE_KEEP_RAW:false} - self-check-on-startup: ${XT_STORAGE_SELF_CHECK:true} + # bucket 内的目录前缀 —— 本项目的文件都放在 shenxue/ 下 + prefix: shenxue + # 默认只保留压缩改名后的规范件(客户确认过,不需要原图当凭证) + keep-raw: "${XT_STORAGE_KEEP_RAW:false}" + # 启动时做一次真实的 写入 → 存在 → 读回 → 删除 自检。 + # 换存储 / 换密钥 / 换空间之后值得打开:它能在启动时就把 + # "密钥错/空间名错/没有写权限"暴露出来,而不是等第一个学生上传时才失败。 + self-check-on-startup: "${XT_STORAGE_SELF_CHECK:true}" qiniu: - access-key: ${QINIU_ACCESS_KEY} - secret-key: ${QINIU_SECRET_KEY} - bucket: ${QINIU_BUCKET} - domain: ${QINIU_DOMAIN} - region: ${QINIU_REGION:auto} + access-key: "t1PIPGcvBY9lJVXFZFb48maTQsGGhvLsR5QQlNq0" + secret-key: "KGooFdF5eCLdCIMCOD6x5ofMzu4vYE17T5Mvp9qC" + bucket: "file-jxit-private" + domain: "https://file.work.jxit.com" + # auto = 让 SDK 自己查空间在哪个区(推荐)。 + # 「华东-浙江」在新旧控制台对应 z0 与 cn-east-2 两个 id,写错了的症状是上传超时。 + region: "${QINIU_REGION:auto}" + # ⚠️ 空间是**私有**的 → 下载走签名链接(带有效期)。 + # 因此 URL 不能入库、不能发出去让人明天再打开,每次要用现取。 signed-url-expire-seconds: 1800 + # ⚠️ 生产默认关闭初始数据装配,避免默认口令(platform / Platform@123)进生产。 + # 首次部署需要建平台管理员时,用 XT_SEED_ENABLED=true 启动一次; + # 它会同时建一个 demo 示范租户,登录后请改掉默认密码并清理 demo 租户, + # 然后把这里改回 false 再重启。 + seed: + enabled: "${XT_SEED_ENABLED:false}" + mini-program: - app-id: ${WX_MP_APP_ID} - app-secret: ${WX_MP_APP_SECRET} + app-id: "${WX_MP_APP_ID:wxf01d3fa60f87f0b7}" + app-secret: "${WX_MP_APP_SECRET:d208f690aab50ec7c5c63e6bbeca6a27}" # ⚠️ 正式发布后用 release。用 trial/develop 生成的码扫开会提示 # 「小程序未发布」,而**接口不报错** —— 只会让人以为码生成得不对 - env-version: ${WX_MP_ENV_VERSION:release} + env-version: "${WX_MP_ENV_VERSION:release}" page: pages/index/index - public-base-url: ${WX_MP_PUBLIC_BASE_URL} + # 图片对外前缀。留空时 qrcodeUrl 返回相对路径(/api/v1/qrcodes/xxx.jpg), + # 由前端用当前域名去拼;前后端同域时这样最省事。 + public-base-url: "${WX_MP_PUBLIC_BASE_URL:}" code-cache-days: 30 + # 微信「服务器配置」里的 Token(**不是** appSecret),用于消息推送回调验签。 + # 下面已生成一个随机值;要与微信后台填的**完全一致**,否则回调验签失败。 + # 不用消息推送回调时可以留空 —— 回调接口会明确拒绝,而不是跳过验签。 + callback-token: "${WX_MP_CALLBACK_TOKEN:7c6f88f58fc2a60eefb0b96094d6a935}" + # 微信订阅消息模板 id(形如 ZG8k...):模板码 → 模板 id。 + # 没配置的模板码会自动降级为站内信,不会报错;不要在没建模板时填占位值。 + # 可用模板码:SUBMIT_SUCCESS / AUDIT_RESULT / DEADLINE_REMIND / EXPORT_READY / NOTIFY_UNSUBMITTED + templates: {} - mp-h5: - app-id: ${WX_H5_APP_ID} - app-secret: ${WX_H5_APP_SECRET} - callback-url: ${WX_H5_CALLBACK} - h5-base-url: ${WX_H5_BASE_URL} - # ⚠️⚠️ 生产**必须** false。开了它就是"任何人可登录任何学生"。 - # 这是 ProdGuard 的第一条检查,写成 true 直接拒绝启动。 - stub-enabled: false + # 不配置 mp-h5:学生端只发布微信小程序,不做 H5 网页授权。 + # 后端 /api/v1/mp/h5/** 接口仍在,未配置时会明确报错; + # 生产不提供 H5 页面,也不再需要公众号的 appid / appsecret。 + # 租户域名(用子域前缀区分租户):t{tenantId}.{base},如 t1.shenxue.jxit.com。 + # 客户 2026-09-29 决定先不做,所以默认关闭;能力本身保留, + # 将来要有正式域名 + 泛域名解析 + 泛域名证书之后才打开。 tenant-domain: - enabled: ${XT_TENANT_DOMAIN_ENABLED:false} - base: ${XT_TENANT_DOMAIN_BASE:} + enabled: "${XT_TENANT_DOMAIN_ENABLED:false}" + base: "${XT_TENANT_DOMAIN_BASE:shenxue.jxit.com}" scheme: https diff --git a/deploy/prod.env.example b/deploy/prod.env.example new file mode 100644 index 0000000..300a78a --- /dev/null +++ b/deploy/prod.env.example @@ -0,0 +1,74 @@ +# ═══════════════════════════════════════════════════════════ +# 生产环境变量模板(prod) +# +# 用法: +# cp deploy/prod.env.example deploy/prod.env +# chmod 600 deploy/prod.env +# # 按下面命令生成四个密钥并填进去,再填 PG / REDIS / QINIU / WX +# set -a; . ./deploy/prod.env; set +a +# java -jar xt-server.jar --spring.profiles.active=prod +# +# 1Panel:把每一条粘到「应用 → 环境变量」,**值不要带引号**。 +# +# ⚠️ deploy/prod.env 已 gitignore。不要把真值写回本文件。 +# ⚠️ 2026-10 曾把 1Panel 的库/Redis 密码直接写进 application-prod.yml +# 并提交。那些值应视为已泄露:要么轮换,要么确认仓库只有可信的人能看。 +# ═══════════════════════════════════════════════════════════ + +# ── PostgreSQL ──────────────────────────────────────────── +# 1Panel 里通常就是容器名;换成你的实例地址 +PG_HOST= +PG_PORT=5432 +PG_DB= +PG_USER= +PG_PASSWORD= + +# ── Redis ───────────────────────────────────────────────── +REDIS_HOST= +REDIS_PORT=6379 +REDIS_PASSWORD= + +# ── 四个密钥(必填,ProdEnvCheck 缺一个就拒绝启动)───────── +# +# XT_JWT_SECRET openssl rand -base64 64 | tr -d '\n' +# XT_AES_KEY openssl rand -base64 32 | tr -d '\n' +# XT_HMAC_KEY openssl rand -base64 48 | tr -d '\n' +# XT_RSA_PRIVATE_KEY openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out prod-rsa.pem +# openssl pkcs8 -topk8 -nocrypt -in prod-rsa.pem -outform DER | base64 -w0 +# +# ⚠️ XT_AES_KEY 一旦用来加密过证件号就不能直接换(旧密文解不开且不报错)。 +XT_JWT_SECRET= +XT_AES_KEY= +XT_HMAC_KEY= +XT_RSA_PRIVATE_KEY= + +# ── 对象存储(生产必须 qiniu)────────────────────────────── +XT_STORAGE_PROVIDER=qiniu +QINIU_ACCESS_KEY= +QINIU_SECRET_KEY= +QINIU_BUCKET= +QINIU_DOMAIN= +QINIU_REGION=auto +# 启动时真的走一遍 写入→存在→读回→删除;密钥/空间配错会当场暴露 +XT_STORAGE_SELF_CHECK=true +# false = 只保留压缩后的规范件(默认) +XT_STORAGE_KEEP_RAW=false + +# ── 微信:小程序 ────────────────────────────────────────── +WX_MP_APP_ID= +WX_MP_APP_SECRET= +WX_MP_PUBLIC_BASE_URL= +WX_MP_ENV_VERSION=release + +# ── 微信:公众号网页授权(H5)───────────────────────────── +# 回调与页面都必须是 https 正式域名,且与 H5 站点同源 +WX_H5_APP_ID= +WX_H5_APP_SECRET= +WX_H5_CALLBACK= +WX_H5_BASE_URL= + +# ── 其他 ───────────────────────────────────────────────── +XT_LOG_DIR=/var/log/xt +# 按域名区分单位时才打开 +XT_TENANT_DOMAIN_ENABLED=false +XT_TENANT_DOMAIN_BASE= diff --git a/frontend-mp/.env.production b/frontend-mp/.env.production new file mode 100644 index 0000000..0b518ab --- /dev/null +++ b/frontend-mp/.env.production @@ -0,0 +1,9 @@ +# 微信小程序生产包(`pnpm build:mp-weixin`)的环境变量 +# +# ⚠️ 必须是 **HTTPS 正式域名**,并且要在微信公众平台 +# 「开发管理 → 开发设置 → 服务器域名」里配成 **request 合法域名**。 +# 否则真机上所有请求都会以 `request:fail url not in domain list` 失败。 +# +# ⚠️ 这里指向后端 API 的地址(PC 管理端 .env.production 的 VITE_API_BASE_URL 同源), +# 不是 mini program 的 appid/secret —— 那两项在后端 application-prod.yml 里。 +VITE_MP_API_BASE = 'https://admin.shenxue.jxit.com' diff --git a/frontend-mp/src/config/index.ts b/frontend-mp/src/config/index.ts index 63c13fc..d74bcfb 100644 --- a/frontend-mp/src/config/index.ts +++ b/frontend-mp/src/config/index.ts @@ -43,7 +43,18 @@ export const API_BASE_URL = // #endif // #ifndef H5 -export const API_BASE_URL = 'http://localhost:8080' +/** + * 小程序:没有"同域"这个概念,必须写绝对地址。 + * + * 正式包用构建变量 `VITE_MP_API_BASE` 指定(见 `.env.production`): + * 必须是 **HTTPS 正式域名**,并且要在微信公众平台 + * 「开发管理 → 开发设置 → 服务器域名」里配成 **request 合法域名**。 + * + * 不指定时回退到本机 8080 —— 只用于微信开发者工具调试 + * (需要在「详情 → 本地设置」里勾"不校验合法域名")。 + */ +const MP_API_BASE = (import.meta.env?.VITE_MP_API_BASE ?? '') as string +export const API_BASE_URL = MP_API_BASE !== '' ? MP_API_BASE : 'http://localhost:8080' // #endif /** 主题色(与 PC 管理端同一套腾讯云 TDesign 色板,两端视觉要一致)。 */ diff --git a/frontend-mp/src/pages/index/index.vue b/frontend-mp/src/pages/index/index.vue index 46e8923..4f5d462 100644 --- a/frontend-mp/src/pages/index/index.vue +++ b/frontend-mp/src/pages/index/index.vue @@ -44,6 +44,7 @@ import { computed, ref } from 'vue' import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app' import { request } from '@/utils/request' +import { getToken, loginByWechat } from '@/utils/auth' /** * 扫码落地页。 @@ -89,15 +90,6 @@ const isError = ref(false) const loading = ref(false) const title = ref('材料填报') -/** 取登录态(授权回调把 token 放在 URL fragment 里,落地页存下来的)。 */ -function token(): string { - try { - return uni.getStorageSync('token') || '' - } catch { - return '' - } -} - /** * 当前 URL 里的 scene(授权回调回来时在 hash 路由的 query 里)。 * @@ -158,7 +150,7 @@ async function load() { // ★ 没有 scene 但有登录态 → 说明他是**回来看进度的**,不是扫码进来的。 // 以前这种情况一律报错"请扫描二维码进入",于是学生一旦找不到老师发的那条消息 // 就再也进不来了(通知渠道还没做,他只能靠历史消息)。现在直接送去「我的填报」。 - if (token()) { + if (getToken()) { uni.redirectTo({ url: '/pages/tasks/tasks' }) return } @@ -188,7 +180,7 @@ async function load() { } } -function start() { +async function start() { const s = scene.value || sceneFromUrl() if (!s) { uni.showModal({ @@ -198,10 +190,69 @@ function start() { }) return } + + // #ifdef MP-WEIXIN + // 扫码进来时 bootstrap 已经登录过;这里再兜一次:网络抖动、第一次登录失败后, + // 学生点"开始填报"时还有一次机会,而不是带着空 token 去核验页吃 40101。 + if (!getToken()) { + try { + await loginByWechat(s) + } catch (e) { + uni.showModal({ + title: '登录失败', + content: e instanceof Error ? e.message : '微信登录失败,请稍后重试', + showCancel: false + }) + return + } + } + // #endif + // 带上 scene —— 见 sceneFromUrl 的说明(H5 跳转不保留 URL 参数) uni.navigateTo({ url: `/pages/verify/verify?scene=${encodeURIComponent(s)}` }) } +/** + * 落地页启动。 + * + * - 小程序:带 scene 进来就先 `wx.login` 换一次 token(token 7 天有效, + * 旧 token 过期后直接去核验页只会得到 40101); + * - 再读 scene-context,把"哪个学校 / 什么任务 / 能不能填"显示出来。 + * + * 登录放在读任务信息**之前**:登录只需要一次 code 交换,很快; + * 这样页面渲染出「开始填报」时 token 一定已经落盘,不会出现 + * "学生点得比登录快"导致同一页里发两次 `wx.login`。 + * 登录失败也不挡任务信息展示 —— 点「开始填报」时会再试一次并明确报错。 + */ +async function bootstrap() { + // #ifdef MP-WEIXIN + let loginError = '' + if (scene.value) { + loading.value = true + try { + // 每次带 scene 进来都换一次 token:小程序 token 有效期 7 天, + // 拿过期 token 去核验页只会得到 40101。登录本身很便宜(一次 code 换 openid)。 + await loginByWechat(scene.value) + } catch (e) { + // 已有 token 时登录失败不致命(可能只是网络抖动),继续用旧 token; + // 连 token 都没有才把错误显示出来。 + if (!getToken()) { + loginError = e instanceof Error ? e.message : '微信登录失败' + } + } + } + // #endif + + await load() + + // #ifdef MP-WEIXIN + if (loginError) { + isError.value = true + message.value = loginError + } + // #endif +} + onLoad((options) => { rawScene.value = options?.scene || options?.q || '' scene.value = pickScene(options) @@ -225,7 +276,7 @@ onLoad((options) => { } } - load() + void bootstrap() }) // 下拉刷新:任务被截止/恢复后,学生不必杀进程重进 diff --git a/frontend-mp/src/utils/auth.ts b/frontend-mp/src/utils/auth.ts new file mode 100644 index 0000000..3be49b5 --- /dev/null +++ b/frontend-mp/src/utils/auth.ts @@ -0,0 +1,142 @@ +import { request } from '@/utils/request' + +/** + * 微信小程序登录(契约 §5.3 的 `/mp/login`)。 + * + *

为什么必须由前端调一次

+ * + * `wx.login()` 拿到的 `code` **一次有效、几分钟过期**,只能由小程序端现取。 + * 后端拿它去 `jscode2session` 换 openid,再按 scene 里的租户找/建学生账号。 + * 没有这一步就没有 token —— 后面所有接口都会 40101。 + * + *

与 H5 网页授权的区别

+ * + * H5 的 token 是后端 302 回来时放在 URL 里的(见 pages/index/index.vue 的 + * `options.token`);小程序没有这个回调,必须主动调接口换。 + * 生产已不部署 H5,学生端只走这一条路。 + */ + +/** 与后端 MpLoginService.LoginVO 对齐(字段名不要改,后端直接按 JSON 返回)。 */ +export interface MpLoginResult { + token: string + expireSeconds: number + needBindPhone: boolean + user: { + id: number + realName: string | null + userType: number + } + /** 扫的是任务码、且这个学生已经绑定名册时才有。 */ + boundRoster?: { + taskId: number + taskName: string | null + rosterId: number + } | null +} + +const TOKEN_KEY = 'token' +const NEED_BIND_PHONE_KEY = 'needBindPhone' +const USER_KEY = 'mpUser' +const BOUND_ROSTER_KEY = 'boundRoster' + +/** 登录态(H5 与小程序共用同一个 storage key)。 */ +export function getToken(): string { + try { + return uni.getStorageSync(TOKEN_KEY) || '' + } catch { + return '' + } +} + +/** 清掉登录态(切换学生 / 排查登录问题时用)。 */ +export function clearToken(): void { + try { + uni.removeStorageSync(TOKEN_KEY) + uni.removeStorageSync(NEED_BIND_PHONE_KEY) + uni.removeStorageSync(USER_KEY) + uni.removeStorageSync(BOUND_ROSTER_KEY) + } catch { + // 存储不可用时本来也没有登录态可清 + } +} + +/** 后端提示"手机号还没绑"。页面上没有绑定入口时先存下来,不影响登录。 */ +export function getNeedBindPhone(): boolean { + try { + return uni.getStorageSync(NEED_BIND_PHONE_KEY) === true + } catch { + return false + } +} + +function saveLoginResult(r: MpLoginResult): void { + try { + uni.setStorageSync(TOKEN_KEY, r.token) + uni.setStorageSync(NEED_BIND_PHONE_KEY, r.needBindPhone === true) + if (r.user) { + uni.setStorageSync(USER_KEY, r.user) + } + if (r.boundRoster) { + uni.setStorageSync(BOUND_ROSTER_KEY, r.boundRoster) + } + } catch { + // ⚠️ 存不进去也不该让登录失败:本次页面还能用返回的 token, + // 只是下次请求会重新走一次登录。 + } +} + +/** + * 小程序登录:`wx.login` 拿 code → `POST /api/v1/mp/login` 换 token。 + * + * ⚠️ `scene` 不能丢:它决定"你属于哪个学校"(统一应用 + scene 识别租户)。 + * 缺失时后端明确报错,不会去猜一个租户 —— 猜错就是 A 校学生登进 B 校。 + */ +export function loginByWechat(scene: string): Promise { + // #ifdef MP-WEIXIN + return new Promise((resolve, reject) => { + if (!scene) { + reject(new Error('缺少 scene,无法确定学校;请重新扫描班主任发的二维码')) + return + } + uni.login({ + provider: 'weixin', + success: (res) => { + if (!res.code) { + reject(new Error('微信登录失败:没有拿到 code')) + return + } + request({ + url: '/api/v1/mp/login', + method: 'POST', + data: { code: res.code, scene } + }) + .then((data) => { + if (!data || !data.token) { + reject(new Error('登录失败:后端没有返回 token')) + return + } + saveLoginResult(data) + resolve(data) + }) + .catch(reject) + }, + fail: (err) => { + reject(new Error(`微信登录失败:${err.errMsg || '未知原因'}`)) + } + }) + }) + // #endif + // #ifndef MP-WEIXIN + return Promise.reject(new Error('当前环境不是微信小程序,无法使用微信登录')) + // #endif +} + +/** 已有 token 就直接用;没有就走一次微信登录。 */ +export async function ensureLogin(scene: string): Promise { + const existing = getToken() + if (existing) { + return existing + } + const result = await loginByWechat(scene) + return result.token +} diff --git a/frontend-pc/.env.production b/frontend-pc/.env.production index 1cdf6c6..94d20a0 100644 --- a/frontend-pc/.env.production +++ b/frontend-pc/.env.production @@ -2,5 +2,5 @@ # 生产走同源反向代理 VITE_API_PREFIX = '/api/v1' -VITE_API_BASE_URL = 'https://api.shenxue.jxit.com' +VITE_API_BASE_URL = 'https://admin.shenxue.jxit.com' VITE_BASE = '/'