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:
@@ -70,3 +70,7 @@ backend/data/
|
||||
application-local.yml
|
||||
# 本地私有配置(云存储密钥等)—— 只提交 .example 模板
|
||||
xt-local.yml
|
||||
|
||||
# 生产环境变量(含 JWT / AES / RSA / 七牛真凭据)—— 只提交 .example 模板
|
||||
deploy/prod.env
|
||||
deploy/prod-rsa.pem
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+22
-6
@@ -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
|
||||
|
||||
@@ -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=
|
||||
@@ -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'
|
||||
@@ -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 色板,两端视觉要一致)。 */
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
// 下拉刷新:任务被截止/恢复后,学生不必杀进程重进
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = '/'
|
||||
|
||||
Reference in New Issue
Block a user