feat(role,scope)!: 角色与授权可配 + 需求收敛(4 角色 / 10 菜单 / 删两模块)

角色与授权(交接摘要 §一·三十五):
- 权限判定合并两个来源:内置角色按代码(BuiltinRole)、自定义角色按库
  (sys_role_permission)。实现放 module 层的 DbRoleGrantSource,security 只依赖
  接口 RoleGrantSource —— 否则角色服务要调 permissionChecker,两个包迟早成环
- 权限判定必须加缓存:此前是零 IO,而压测基线是"SQL 条数恒定"。缓存放 Redis,
  空结果也缓存(否则已删角色的 token 会把库打穿)、Redis 挂了退化成直读库、TTL 只兜底
- V18 建 sys_role.user_type(账号的 user_type 一直从角色反推,自定义角色不在代码映射里)
  与 sys_role_menu;8 个接口 + 错误码 40045;契约 119 -> 129
- 内置角色只读(权限码是前后端契约,做成可改 = 一次误操作就能把管理员锁在系统外);
  平台级能力两道防线(界面禁用 + 服务端 40301);防自锁;改完立即生效(写授权清缓存)
- RoleProvisioner 从"已存在就 continue"改成幂等回填 + 每日 03:20 定时任务 ——
  修的是"代码改了权限、已开通租户永远停在发放那一刻",这种漂移不报错也没测试会红
- scripts/e2e-m10-role.py 77/77

需求收敛(交接摘要 §一·三十六,本节的数字为准):
- 删副班主任 / 审核员两个内置角色(6 -> 4)、历史整理器(module/cleanup)、
  加入申请(module/join + 学生端 pages/join)、collect_task_org.deputy_user_ids 整列;
  审核范围三级回退 -> 两级,连带 6 个 mapper 的 LIKE '%,id,%' 分支
- V19 迁移处理存量:先解 sys_user_role 绑定再软删角色;账号只在"除这两个角色外没有
  别的角色"时才软删。必须迁而不能只删代码 —— 角色码删掉后 mergePermissions 返回空集,
  存量账号会"能登录、处处 40301",不抛异常不打日志
- 契约 129 -> 119、错误码 59 -> 54、权限码 30 -> 28、PC 菜单 12 -> 10、
  小程序页 10 -> 9、原型 32 -> 29 屏、E2E 39 -> 37 个(断言 1695 条)
- UserService.list 对不认识的角色码由"静默丢掉过滤条件"改成失败关闭
  (之前传个不存在的 roleCode 会返回全校账号,且看起来完全正常)
- 品牌元信息同步改名「深学在线」:pom 名称、/doc.html 标题、配置与启动类注释、
  存储自检探针内容;学生端 app 名与全局导航栏标题
This commit is contained in:
2026-10-10 16:48:54 +08:00
parent 5b877451eb
commit 1f3c485ceb
84 changed files with 2845 additions and 4904 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name" : "填报通",
"name" : "深学在线",
"appid" : "",
"description" : "",
"versionName" : "1.0.0",
+1 -9
View File
@@ -25,14 +25,6 @@
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/join/join",
"style": {
"navigationBarTitleText": "申请加入名单",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/tasks/tasks",
"style": {
@@ -87,7 +79,7 @@
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "填报通",
"navigationBarTitleText": "深学在线",
"navigationBarBackgroundColor": "#0052D9",
"backgroundColor": "#F3F3F3"
}
@@ -78,7 +78,7 @@
<!-- 学生改动 -->
<view v-if="(d.changedDetail || []).length" class="det__card">
<view class="det__title">学生改动({{ d.changedDetail.length }} 项)</view>
<view class="det__title">学生改动({{ (d.changedDetail || []).length }} 项)</view>
<view v-for="c in d.changedDetail" :key="c.field" class="det__change">
<text class="det__change-label">{{ c.label || c.field }}</text>
<text class="det__muted">名册:{{ c.rosterValue || '—' }} → 学生:{{ c.studentValue || '—' }}</text>
@@ -94,29 +94,16 @@
</view>
<!--
代传入口(§10.6):只在这个学生**还没提交**时才有意义 ——
已提交的单子不能塞材料(那会让审到一半的材料清单变了)。
放在最下面而不是和"通过/驳回"并排:它是补救动作,不是主流程。
⚠️ 2026-10:这里原本有一段「帮他传材料」的模板(代传,§10.6),
但它引用的 `canProxy / proxyMaterial / proxyReason / proxyBusy / doProxy`
**五个变量在脚本里从未定义** —— 也就是这一段**永远不会渲染**,
而 vue-tsc 一直在报 TS2339,看起来像"页面写坏了"。
现在直接删掉:需求收敛后主流程里没有代传(学生填报 → 班主任/管理员审核),
后端 `file:proxyUpload` 接口还在(`e2e-m5-proxy.py` 33 条断言锁着),
但**两端都没有代传入口**。将来要做,请重新实现并补 UI 断言,
不要照着这段删掉的模板"恢复"。
-->
<view v-if="canProxy" class="det__proxy">
<view class="det__proxy-title">帮他传材料</view>
<view class="det__proxy-hint">
学生不会弄(照片在家长手机里 / 不会压缩)时用这里。
<text class="det__strong">这份材料会标注「由你代传」</text>,学生也看得到。
</view>
<view class="det__proxy-mats">
<view v-for="g in (d.materials || []).filter((m) => m.required)" :key="g.materialCode"
class="det__proxy-mat" :class="{ 'det__proxy-mat--on': proxyMaterial === g.materialCode }"
@click="proxyMaterial = g.materialCode">
{{ g.materialName }}{{ g.done ? '(已交)' : '' }}
</view>
</view>
<input v-model="proxyReason" class="det__proxy-reason"
placeholder="代传原因(选填,但学生看得到)" :maxlength="100" />
<button class="det__btn det__btn--ghost" :disabled="proxyBusy" @click="doProxy">
{{ proxyBusy ? '正在上传…' : '选择照片并代传' }}
</button>
</view>
</template>
<!-- 驳回弹层:原因快选 + 必须二次确认 -->
+1 -1
View File
@@ -48,7 +48,7 @@
红 {{ cur.redCount }} · 黄 {{ cur.yellowCount }}
</view>
<view v-if="(cur.changedFields || []).length" class="audit__changed">
学生改了 {{ cur.changedFields.length }} 项预填内容
学生改了 {{ (cur.changedFields || []).length }} 项预填内容
</view>
<view class="audit__thumbs">
@@ -19,7 +19,7 @@
<!-- 待办:这一屏最该被点的是"待我初审" -->
<view class="dash__todos">
<view class="dash__todo" @click="goAudit">
<view class="dash__todo" @click="goAudit()">
<text class="dash__todo-num">{{ t.auditCount }}</text>
<text class="dash__todo-label">待我初审</text>
</view>
+1 -1
View File
@@ -519,7 +519,7 @@ function uploadFor(m: Material) {
let sendPath = path
// 选中的原始文件名。**H5 用 blob 地址上传时 multipart 里带不过去**(是一串 UUID、
// 没有后缀),所以下面要显式告诉服务端叫什么 —— 否则它按后缀判格式会直接拒掉。
const pickedName = ((res.tempFiles || [])[0] as { name?: string } | undefined)?.name || ''
const pickedName = ((res.tempFiles as Array<{ name?: string }> | undefined)?.[0])?.name || ''
try {
const plan = planFor(m.compressProfile, m.maxSizeKb, m.minQuality)
if (plan) {
+1 -1
View File
@@ -2,7 +2,7 @@
<view class="land">
<!-- 顶部:这是哪个学校、要填什么 -->
<view class="land__hero">
<text class="land__tenant">{{ ctx.tenantName || '填报通' }}</text>
<text class="land__tenant">{{ ctx.tenantName || '深学在线' }}</text>
<text class="land__task">{{ ctx.taskName || title }}</text>
<view v-if="ctx.taskStatusText" class="land__badge" :class="badgeClass">
{{ ctx.taskStatusText }}
-466
View File
@@ -1,466 +0,0 @@
<template>
<view class="join">
<view class="join__wrap">
<view class="join__hero">
<text class="join__tenant">{{ ctx.tenantName || '材料填报' }}</text>
<text class="join__task">{{ ctx.taskName }}</text>
</view>
<!--
① 已经申请过:把状态摆在最上面。
这条通道最容易让人误解的就是"我提交了,是不是就能填了?" ——
所以三个状态各有一句话直说结果,尤其待审时要说清**还不能填**。
-->
<view v-if="mine" class="join__card">
<view class="join__status" :class="`join__status--${mine.status}`">
{{ mine.statusText }}
</view>
<view class="join__row">
<text class="join__row-label">申请编号</text>
<text class="join__row-value">#{{ mine.requestId }}</text>
</view>
<view class="join__row">
<text class="join__row-label">姓名</text>
<text class="join__row-value">{{ mine.realName }}</text>
</view>
<view class="join__row">
<text class="join__row-label">身份证号</text>
<text class="join__row-value">{{ mine.idCardMask }}</text>
</view>
<view class="join__row">
<text class="join__row-label">提交时间</text>
<text class="join__row-value">{{ fmt(mine.createTime) }}</text>
</view>
<!-- 待审:说清"还不能填",并给出他能做的事 -->
<view v-if="mine.status === 0" class="join__note">
老师审核通过后,你<text class="join__strong">返回重新核验</text>就能开始填材料。
审核前暂时还不能填 —— 名单是准入依据,这一步不能跳过。
</view>
<!-- 通过:直接给"去核验"的入口 -->
<view v-else-if="mine.status === 1" class="join__note join__note--ok">
已经加入名单了,点下面的按钮重新核验一次即可开始填报。
</view>
<!-- 驳回:原因原样显示(这是学生唯一能知道"哪里不对"的来源) -->
<view v-else class="join__note join__note--bad">
<text class="join__strong">未通过原因:</text>{{ mine.rejectReason || '(老师未填写)' }}
</view>
<button v-if="mine.status === 1" class="join__btn" @click="reverify">
重新核验,开始填报
</button>
<button v-if="mine.status === 2" class="join__btn join__btn--ghost"
:disabled="submitting" @click="showForm = true">
重新填写申请(还剩 {{ remainHint }})
</button>
</view>
<!-- ② 填申请 -->
<view v-if="!mine || mine.status === 2" class="join__card">
<template v-if="showForm">
<view class="join__title">申请加入名单</view>
<view class="join__hint">
核对不上,通常是因为<text class="join__strong">老师名单里还没有你</text>,
或者姓名写得和报名时不一样。填一次申请,老师核对后会把你加进名单。
</view>
<view class="join__field">
<text class="join__label">姓名</text>
<input v-model="form.realName" class="join__input" placeholder="与报名时一致的姓名"
:disabled="submitting" />
</view>
<view class="join__field">
<text class="join__label">身份证号</text>
<input v-model="form.idCardNo" class="join__input" placeholder="18 位身份证号"
maxlength="18" :disabled="submitting" />
</view>
<view class="join__field">
<text class="join__label">手机号</text>
<input v-model="form.phone" class="join__input" placeholder="老师要能联系到你"
maxlength="11" type="number" :disabled="submitting" />
</view>
<view class="join__field">
<text class="join__label">班级(选填)</text>
<input v-model="form.orgHint" class="join__input" placeholder="如 24中技数控2班"
:disabled="submitting" />
</view>
<view class="join__field">
<text class="join__label">说明(选填)</text>
<input v-model="form.reason" class="join__input" placeholder="如:我是转班过来的"
:disabled="submitting" />
</view>
<button class="join__btn" :disabled="submitting || !canSubmit" @click="submit">
{{ submitting ? '提交中…' : '提交申请' }}
</button>
<view v-if="error" class="join__error">{{ error }}</view>
<view class="join__foot">
身份证号只用于与名单核对,系统<text class="join__strong">不会保存明文</text>。
</view>
</template>
<template v-else>
<view class="join__title">还没有加入名单?</view>
<view class="join__hint">
如果你确认姓名和身份证号没填错,但仍然核对不上,可以在这里提交申请,
由老师核对后加入名单。
</view>
<button class="join__btn" @click="showForm = true">申请加入名单</button>
</template>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 申请加入名单(FR-509 / 客户确认后新增,原型 STEP 03B)。
*
* ## 三条必须说清的事(文案不是装饰,是这段流程的一部分)
*
* 1. **申请期间仍然不能填报。** 这条通道只解决"进名单",不绕过门禁 ——
* 学生在待审状态最容易以为"我交了就能填",所以状态卡上直说"暂时还不能填"。
* 2. **通过后要重新核验一次。** 通过时服务端已经把他绑到名册行上了,
* 所以重进就能过;说清这一步,他就不会以为"通过了还要再填一遍资料"。
* 3. **身份证号不存明文。** 未核验的人最该被告知这一点 ——
* 否则"填身份证号"这一步会让人犹豫。
*/
defineOptions({ name: 'JoinRequest' })
interface SceneContext {
tenantName?: string
taskName?: string
taskId?: number
}
interface MineRequest {
requestId: number
taskId: number
taskName?: string
status: number
statusText: string
realName: string
idCardMask: string
orgHint?: string
createTime?: string
auditTime?: string
rejectReason?: string
rosterId?: number
canFillNow?: boolean
}
const ctx = ref<SceneContext>({})
const mine = ref<MineRequest | null>(null)
const showForm = ref(false)
const submitting = ref(false)
const error = ref('')
let taskId = 0
let scene = ''
const form = ref({
realName: '',
idCardNo: '',
phone: '',
orgHint: '',
reason: ''
})
const canSubmit = computed(() => {
const f = form.value
return f.realName.trim().length > 0
&& f.idCardNo.trim().length === 18
&& /^1[3-9]\d{9}$/.test(f.phone.trim())
})
const remainHint = computed(() => '最多 2 次机会')
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
function fmt(v?: string) {
return v ? v.replace('T', ' ').slice(0, 16) : '—'
}
async function loadContext(s: string) {
if (!s) return
try {
const data = await request<SceneContext>({
url: `/api/v1/mp/scene-context?scene=${encodeURIComponent(s)}`
})
ctx.value = data
taskId = data.taskId || 0
} catch (e) {
error.value = e instanceof Error ? e.message : '读取任务信息失败'
}
}
/** 拉"我的申请":决定显示状态卡还是表单 */
async function loadMine() {
try {
const data = await request<{ list: MineRequest[], total: number }>({
url: `/api/v1/mp/roster/join-request${taskId ? `?taskId=${taskId}` : ''}`,
token: token()
})
// 只看最近一条:一个人在同一任务下的申请按时间倒序,最新那条决定他现在的处境
mine.value = (data.list || [])[0] || null
} catch (e) {
// 拉不到不算致命:最多是少显示状态,仍可提交
error.value = e instanceof Error ? e.message : ''
}
}
async function submit() {
error.value = ''
const f = form.value
if (!canSubmit.value) {
error.value = '请把姓名、18 位身份证号、手机号填完整'
return
}
submitting.value = true
try {
await request({
url: '/api/v1/mp/roster/join-request',
method: 'POST',
token: token(),
data: {
taskId,
realName: f.realName.trim(),
idCardNo: f.idCardNo.trim(),
phone: f.phone.trim(),
orgHint: f.orgHint.trim() || undefined,
reason: f.reason.trim() || undefined
}
})
uni.showToast({ title: '已提交,等待老师审核', icon: 'none', duration: 2500 })
showForm.value = false
await loadMine()
} catch (e) {
// 后端的话原样显示("你已在名单中,请直接返回重新核验"这类提示必须让学生看到)
error.value = e instanceof Error ? e.message : '提交失败'
} finally {
submitting.value = false
}
}
/** 通过之后回核验页 —— 服务端已绑定,重进就能过 */
function reverify() {
uni.redirectTo({ url: `/pages/verify/verify?scene=${encodeURIComponent(scene)}` })
}
onLoad(async (options) => {
scene = options?.scene || ''
taskId = Number(options?.taskId || 0)
// 从核验页带过来的姓名/证件号:他刚打过一遍,不必再打
form.value.realName = options?.name || ''
form.value.idCardNo = options?.idCardNo || ''
await loadContext(scene)
await loadMine()
// 带过来的是"门禁没匹配上"的那份输入 —— 直接展开表单,少一次点击
if (!mine.value && (form.value.realName || form.value.idCardNo)) {
showForm.value = true
}
})
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$warn: #ed7b2f;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.join {
min-height: 100vh;
padding: 24rpx;
&__wrap {
max-width: 720rpx;
margin: 0 auto;
}
&__hero {
padding: 32rpx 28rpx;
margin-bottom: 24rpx;
background: linear-gradient(135deg, $primary 0%, #2b6fe0 100%);
border-radius: 16rpx;
}
&__tenant {
display: block;
font-size: 24rpx;
color: rgb(255 255 255 / 80%);
}
&__task {
display: block;
margin-top: 8rpx;
font-size: 34rpx;
font-weight: 600;
color: #fff;
}
&__card {
padding: 32rpx 28rpx;
margin-bottom: 24rpx;
background: #fff;
border-radius: 16rpx;
}
&__title {
font-size: 34rpx;
font-weight: 600;
color: #1b2430;
}
&__status {
display: inline-block;
padding: 6rpx 18rpx;
margin-bottom: 20rpx;
font-size: 26rpx;
border-radius: 8rpx;
&--0 {
color: $warn;
background: #fdf2e9;
}
&--1 {
color: $success;
background: #e8f6f0;
}
&--2 {
color: $danger;
background: #fdecee;
}
}
&__hint {
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.7;
color: #4a5768;
}
&__strong {
font-weight: 600;
color: #1b2430;
}
&__row {
display: flex;
justify-content: space-between;
padding: 14rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
&__row-label {
font-size: 26rpx;
color: #8b95a3;
}
&__row-value {
font-size: 26rpx;
color: #1b2430;
}
&__note {
padding: 20rpx;
margin-top: 20rpx;
font-size: 25rpx;
line-height: 1.7;
color: #4a5768;
background: #f7f8fa;
border-radius: 12rpx;
&--ok {
color: #2f6b4f;
background: #e8f6f0;
}
&--bad {
color: #a03a44;
background: #fdecee;
}
}
&__field {
margin-top: 24rpx;
}
&__label {
display: block;
margin-bottom: 10rpx;
font-size: 26rpx;
color: #4a5768;
}
&__input {
width: 100%;
height: 84rpx;
padding: 0 20rpx;
font-size: 30rpx;
background: #f7f8fa;
border-radius: 12rpx;
}
&__btn {
height: 88rpx;
margin-top: 36rpx;
font-size: 32rpx;
color: #fff;
background: $primary;
border-radius: 12rpx;
&[disabled] {
color: #fff;
background: #b9c6dd;
}
&--ghost {
color: $primary;
background: #eaf1fd;
&[disabled] {
color: #8b95a3;
background: #f2f3f5;
}
}
}
&__error {
padding: 18rpx 20rpx;
margin-top: 24rpx;
font-size: 26rpx;
line-height: 1.6;
color: $danger;
background: #fdecee;
border-radius: 12rpx;
}
&__foot {
margin-top: 28rpx;
font-size: 24rpx;
line-height: 1.7;
color: #8b95a3;
}
}
</style>
+1 -1
View File
@@ -45,7 +45,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
+6 -25
View File
@@ -33,18 +33,13 @@
<view v-if="error" class="verify__error">{{ error }}</view>
<!--
核验失败时给一条**受控通道**(FR-509 / 客户确认后新增)。
注意两件事:
1) 这条通道**不绕过门禁** —— 申请期间仍然不能填,去申请页会说清这一点;
2) 它只在真失败过之后才出现(`error` 非空),不是一进来就摆个"申请"按钮。
否则会有学生绕过核验直接去申请,把管理员的待办当成人工客服。
核验失败时**只给"重新填写"这一条路**(2026-10 需求收敛:删掉「申请加入名单」)。
刻意不提任何联系方式:名单外的人怎么进名单是老师那边的事,
系统内不展示班主任/管理员的电话(客户定过的规矩)。
-->
<button v-if="error" class="verify__btn verify__btn--ghost" @click="gotoJoin">
申请加入名单
</button>
<view class="verify__foot">
核对不上?先确认姓名和证件号没有打错。仍然不行可点上面的「申请加入名单」。
<view v-if="error" class="verify__foot">
核对不上?请先确认姓名和身份证号没有打错。
如果确实已经报名,请直接联系老师,让老师把你加进名单后再来核对。
</view>
</view>
@@ -225,20 +220,6 @@ function next() {
})
}
/** 去申请加入名单(FR-509 的入口)—— 带上已填的姓名与证件号,省他再输一遍。 */
function gotoJoin() {
const s = sceneFromUrl()
const params = new URLSearchParams({
scene: s,
taskId: String(ctx.value.taskId || 0),
// 只把**已经打过的字**带过去。这不是"预填答案":门禁刚判定它不匹配,
// 带过去是为了让他少打一遍,而不是暗示它是对的。
name: realName.value.trim(),
idCardNo: idCardNo.value.trim()
})
uni.navigateTo({ url: `/pages/join/join?${params.toString()}` })
}
onLoad((options) => {
loadContext(options?.scene || '')
})