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
+4
View File
@@ -70,3 +70,7 @@ backend/data/
application-local.yml
# 本地私有配置(云存储密钥等)—— 只提交 .example 模板
xt-local.yml
# 生产环境变量(含 JWT / AES / RSA / 七牛真凭据)—— 只提交 .example 模板
deploy/prod.env
deploy/prod-rsa.pem
@@ -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()
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) {
if (r == null) {
return 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> 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
+74
View File
@@ -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=
+9
View File
@@ -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'
+12 -1
View File
@@ -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 色板,两端视觉要一致)。 */
+63 -12
View File
@@ -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()
})
// 下拉刷新:任务被截止/恢复后,学生不必杀进程重进
+142
View File
@@ -0,0 +1,142 @@
import { request } from '@/utils/request'
/**
* 微信小程序登录(契约 §5.3 的 `/mp/login`)。
*
* <h3>为什么必须由前端调一次</h3>
*
* `wx.login()` 拿到的 `code` **一次有效、几分钟过期**,只能由小程序端现取。
* 后端拿它去 `jscode2session` 换 openid,再按 scene 里的租户找/建学生账号。
* 没有这一步就没有 token —— 后面所有接口都会 40101。
*
* <h3>与 H5 网页授权的区别</h3>
*
* 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<MpLoginResult> {
// #ifdef MP-WEIXIN
return new Promise<MpLoginResult>((resolve, reject) => {
if (!scene) {
reject(new Error('缺少 scene,无法确定学校;请重新扫描班主任发的二维码'))
return
}
uni.login({
provider: 'weixin',
success: (res) => {
if (!res.code) {
reject(new Error('微信登录失败:没有拿到 code'))
return
}
request<MpLoginResult>({
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<string> {
const existing = getToken()
if (existing) {
return existing
}
const result = await loginByWechat(scene)
return result.token
}
+1 -1
View File
@@ -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 = '/'