fix(mp): 接通微信小程序登录并补齐生产配置

- 前端落地页调用 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 含生产凭据(按部署要求直接写入),仓库访问范围需收紧
This commit is contained in:
2026-10-20 07:25:57 +08:00
parent 17c92b29c3
commit 1e71c164af
10 changed files with 505 additions and 87 deletions
@@ -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;
}
@@ -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.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, s.taskId())
.eq(CollectRoster::getBindUserId, user.getId())
.last("limit 1"));
if (r != null) {
CollectTask t = taskMapper.selectById(s.taskId());
Map<String, Object> br = new LinkedHashMap<>();
br.put("taskId", s.taskId());
br.put("taskName", t == null ? null : t.getTaskName());
br.put("rosterId", r.getId());
Map<String, Object> br = TenantScope.callAs(
s.tenantId(), user.getId(), Set.of("STUDENT"), () -> {
CollectRoster r = rosterMapper.selectOne(
Wrappers.<CollectRoster>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<String, Object> 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);
}
}
@@ -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