This commit is contained in:
hero920103 committed 2026-11-03 14:55:26 +08:00
1 parent d16dbc69b4
commit fc8c3ee3ad
2 files changed
+370 -113

No files matched your search

+272 -18
View File
@@ -21,6 +21,7 @@ import (
"server/models"
"server/pkg/identity"
"server/pkg/jwtutil"
authsvc "server/services/auth"
"server/services/gueststore"
"server/services/wechatmini"
@@ -83,12 +84,11 @@ func (c *GuestController) MiniLogin() {
return
}
// openid 仅作溯源,拿不到不阻断游客签发
// openid 仅作溯源与身份识别(已绑定过正式身份时按 openid 认人),拿不到不阻断游客签发
var openID, unionID string
if req.Code != "" {
if session, serr := wechatmini.Code2Session(cfg, req.Code); serr == nil && session != nil {
openID, unionID = session.OpenID, session.UnionID
}
session := guestMiniSession(cfg, req.Code)
if session != nil {
openID, unionID = session.OpenID, session.UnionID
}
if strings.TrimSpace(req.PhoneCode) == "" {
@@ -114,6 +114,12 @@ func (c *GuestController) MiniLogin() {
return
}
// 手机号已注册云泽账号(或该微信已绑定正式身份):直接签发正式令牌按登录处理。
// (与 /auth/wechat/miniapp/login 同一套识别与签发逻辑)
if c.tryUserLogin(mobile, session) {
return
}
guest, err := gueststore.Upsert(mobile, openID, unionID)
if err != nil {
logs.Error("[guest] upsert 失败: %v", err)
@@ -143,6 +149,258 @@ func (c *GuestController) MiniLogin() {
})
}
// identityByPhone 按微信已验证手机号定位认证中心身份。
// 租户账号手机号可能落在三处,逐层兜底(先查认证中心,查不到再回退业务表):
// 1. yz_auth_identity.mobile/account/email(FindIdentityByAccount)
// 2. yz_auth_tenant_user.phone → identity_id
// 3. yz_system_tenant_user.phone → uid(全量迁移后 uid == identity_id)
func identityByPhone(mobile string) *models.AuthIdentity {
if ai, err := authsvc.FindIdentityByAccount(mobile); err == nil && ai != nil {
return ai
}
var identityID uint64
var tu models.AuthTenantUser
if err := models.Orm.QueryTable(new(models.AuthTenantUser)).
Filter("phone", mobile).Filter("delete_time__isnull", true).
OrderBy("-is_default", "id").One(&tu); err == nil {
identityID = tu.IdentityID
} else {
var su models.SystemTenantUser
if err2 := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("phone", mobile).Filter("delete_time__isnull", true).
OrderBy("-is_default", "id").One(&su); err2 == nil {
identityID = su.Uid
}
}
if identityID == 0 {
return nil
}
ai := &models.AuthIdentity{}
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("id", identityID).Filter("delete_time__isnull", true).One(ai); err != nil {
return nil
}
return ai
}
// phoneOfIdentity 取身份手机号:identity.mobile 为空时回退租户绑定/业务用户表。
func phoneOfIdentity(identityID uint64) string {
var tus []models.AuthTenantUser
if _, err := models.Orm.QueryTable(new(models.AuthTenantUser)).
Filter("identity_id", identityID).Filter("delete_time__isnull", true).
OrderBy("-is_default", "id").All(&tus); err == nil {
for _, tu := range tus {
if tu.Phone != nil {
if p := strings.TrimSpace(*tu.Phone); p != "" {
return p
}
}
}
}
var sus []models.SystemTenantUser
if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("uid", identityID).Filter("delete_time__isnull", true).
OrderBy("-is_default", "id").All(&sus); err == nil {
for _, su := range sus {
if su.Phone != nil {
if p := strings.TrimSpace(*su.Phone); p != "" {
return p
}
}
}
}
return ""
}
// guestMiniSession 拿 wx.login code 换 openid/unionid;失败不阻断(返回 nil)。
func guestMiniSession(cfg *wechatmini.Config, code string) *wechatmini.Session {
if strings.TrimSpace(code) == "" {
return nil
}
session, err := wechatmini.Code2Session(cfg, code)
if err != nil {
logs.Warn("[guest] code 换取 openid 失败: %v", err)
return nil
}
return session
}
// identityByWechat 按 openid/unionid 查已绑定的正式身份(登录页静默 bind 的落点)。
// 与小程序一键登录 resolveIdentity 同源:手机号查不到时的第二级认人方式。
func identityByWechat(openID, unionID string) *models.AuthIdentity {
var bind models.AuthIdentityThird
qs := models.Orm.QueryTable(new(models.AuthIdentityThird)).Filter("provider", "wechat_mini")
found := false
if openID != "" {
if err := qs.Filter("open_id", openID).One(&bind); err == nil {
found = true
}
}
if !found && unionID != "" {
if err := models.Orm.QueryTable(new(models.AuthIdentityThird)).
Filter("provider", "wechat_mini").Filter("union_id", unionID).One(&bind); err == nil {
found = true
}
}
if !found {
return nil
}
ai := &models.AuthIdentity{}
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("id", bind.IdentityID).Filter("delete_time__isnull", true).One(ai); err != nil {
return nil
}
return ai
}
// bindWechatMini 把当前小程序 openid 绑到该身份。手机号已经微信验证,
// 属于强证据,允许覆盖旧绑定(与 controllers/auth syncBinding 的 verified 分支一致)。
func bindWechatMini(session *wechatmini.Session, identityID uint64) {
var bind models.AuthIdentityThird
qs := models.Orm.QueryTable(new(models.AuthIdentityThird)).Filter("provider", "wechat_mini")
if err := qs.Filter("open_id", session.OpenID).One(&bind); err == nil {
if bind.IdentityID == identityID {
return
}
_, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)).Filter("id", bind.ID).Delete()
}
item := &models.AuthIdentityThird{IdentityID: identityID, Provider: "wechat_mini", OpenID: session.OpenID}
if session.UnionID != "" {
uid := session.UnionID
item.UnionID = &uid
}
_, _ = models.Orm.Insert(item)
}
// tryUserLogin 一键授权时的正式身份识别:手机号命中启用账号且该账号有企业,
// 则签发正式令牌并返回 type=user(对齐小程序一键登录:单企业直接进入,
// 多企业返回待选令牌由企业选择接口完成切换)。已处理返回 true。
// 未命中 / 账号无企业 / 签发失败 → 返回 false,回落到游客签发流程。
func (c *GuestController) tryUserLogin(mobile string, session *wechatmini.Session) bool {
var authIdentity *models.AuthIdentity
if session != nil {
authIdentity = identityByWechat(session.OpenID, session.UnionID)
}
if authIdentity == nil {
authIdentity = identityByPhone(mobile)
}
if authIdentity == nil {
return false
}
// 手机号已通过微信验证:把 openid 补绑/改绑到该身份(下次无手机号也能认人)
if session != nil && session.OpenID != "" {
bindWechatMini(session, authIdentity.ID)
}
if authIdentity.Status != models.AuthIdentityStatusEnabled {
c.gReply(403, "账号已被禁用或锁定,无法使用", nil)
return true
}
tenants, lerr := authsvc.ListTenantOptions(authIdentity.ID)
if lerr != nil || len(tenants) == 0 {
return false // 账号未加入任何企业:按游客模式继续使用
}
needChoose := len(tenants) > 1
tid := tenants[0].Tid
accessTTL := 0
if needChoose {
tid = authsvc.PendingTenantID
accessTTL = 600 // 待选状态令牌只够用户完成企业选择
}
username := "微信用户"
if authIdentity.Nickname != nil && strings.TrimSpace(*authIdentity.Nickname) != "" {
username = strings.TrimSpace(*authIdentity.Nickname)
} else if authIdentity.Mobile != nil && *authIdentity.Mobile != "" {
username = *authIdentity.Mobile
}
sess, serr := authsvc.CreateSession(authsvc.SessionInfo{
IdentityID: authIdentity.ID,
Tid: tid,
ClientID: "yz-miniapp",
IP: c.Ctx.Input.IP(),
UserAgent: c.Ctx.Request.UserAgent(),
LoginType: "wechat_mini",
Amr: "wechat_mini",
})
if serr != nil {
logs.Error("[guest] 正式登录创建会话失败: %v", serr)
return false
}
tokens, terr := authsvc.IssueTokens(authsvc.TokenIssue{
IdentityID: authIdentity.ID,
Tid: tid,
ClientID: "yz-miniapp",
Sid: sess.Sid,
Username: username,
UserType: "app",
Amr: "wechat_mini",
AccessTTL: accessTTL,
})
if terr != nil {
logs.Error("[guest] 正式登录签发令牌失败: %v", terr)
return false
}
identityIDCopy := authIdentity.ID
tidCopy := tid
amr := "wechat_mini"
_, _ = models.Orm.Insert(&models.AuthLoginLog{
Tid: &tidCopy,
IdentityID: &identityIDCopy,
UserName: username,
ClientID: "yz-miniapp",
LoginType: "wechat_mini",
Amr: &amr,
Status: 1,
Message: "小程序手机号一键登录成功(免登录入口识别为正式账号)",
IP: c.Ctx.Input.IP(),
UserAgent: c.Ctx.Request.UserAgent(),
})
c.gReply(200, "success", map[string]interface{}{
"type": "user",
"tokens": tokens,
"tenants": tenants,
"need_choose_tenant": needChoose,
})
return true
}
// findGuestForIdentity 找回当前身份对应的未合并游客记录:
// 优先按手机号,查不到再按该身份已绑定的微信 openid/unionid
// (微信验证号可能与账号注册号不一致,openid 是另一条可靠对应关系)。
func findGuestForIdentity(identityID uint64, mobile string) (models.GuestUser, bool) {
var guest models.GuestUser
if mobile != "" {
err := models.Orm.QueryTable(new(models.GuestUser)).
Filter("phone", mobile).Filter("merged_uid", 0).One(&guest)
if err == nil {
return guest, true
}
}
var binds []models.AuthIdentityThird
if _, err := models.Orm.QueryTable(new(models.AuthIdentityThird)).
Filter("identity_id", identityID).Filter("provider", "wechat_mini").All(&binds); err == nil {
for _, b := range binds {
if b.OpenID != "" {
if err := models.Orm.QueryTable(new(models.GuestUser)).
Filter("openid", b.OpenID).Filter("merged_uid", 0).One(&guest); err == nil {
return guest, true
}
}
if b.UnionID != nil && *b.UnionID != "" {
if err := models.Orm.QueryTable(new(models.GuestUser)).
Filter("unionid", *b.UnionID).Filter("merged_uid", 0).One(&guest); err == nil {
return guest, true
}
}
}
}
return guest, false
}
// Merge POST /api/guest/merge(携带正式令牌)
// 按当前身份手机号找回游客记录,把 yz_guest_data 各模块数据并入正式账号。
func (c *GuestController) Merge() {
@@ -161,24 +419,20 @@ func (c *GuestController) Merge() {
c.gReply(500, "查询身份失败", nil)
return
}
if ai.Mobile == nil || strings.TrimSpace(*ai.Mobile) == "" {
c.gReply(200, "无可合并数据", map[string]interface{}{"merged": false})
return
// 回查手机号用于定位游客记录:identity.mobile 为空时回退租户绑定/业务用户表
var mobile string
if ai.Mobile != nil {
mobile = strings.TrimSpace(*ai.Mobile)
}
if mobile == "" {
mobile = phoneOfIdentity(ai.ID)
}
var guest models.GuestUser
err = models.Orm.QueryTable(new(models.GuestUser)).
Filter("phone", strings.TrimSpace(*ai.Mobile)).
Filter("merged_uid", 0).
One(&guest)
if err == orm.ErrNoRows {
guest, found := findGuestForIdentity(ai.ID, mobile)
if !found {
c.gReply(200, "无可合并数据", map[string]interface{}{"merged": false})
return
}
if err != nil {
c.gReply(500, "查询游客记录失败", nil)
return
}
counts, merr := mergeGuestToUser(&guest, uint64(claims.UserID), claims.TenantId, claims.Username)
if merr != nil {
+98 -95
View File
@@ -56,16 +56,11 @@
</view>
<!-- #ifdef MP-WEIXIN -->
<!-- 游客冷启动授权引导:手机号授权后签发游客令牌,免登录模块数据才能保存 -->
<!-- 游客无感授权:无弹窗,全屏透明按钮——首次点按即拉起微信原生手机号确认层;
后端按手机号自动判定:绑定过租户→直接登录,否则→游客令牌 -->
<view v-if="guestAuthVisible" class="guest-auth-mask">
<view class="guest-auth-card">
<text class="guest-auth-title">开启免登录保存</text>
<text class="guest-auth-desc">授权手机号后,记事本、日程提醒、密码库等免登录功能的数据即可保存;正式登录后会自动并入账号,不会丢失。</text>
<button class="guest-auth-btn" open-type="getPhoneNumber" :disabled="guestAuthLoading" @getphonenumber="onGuestPhone">
{{ guestAuthLoading ? '授权中…' : '立即授权' }}
</button>
<text class="guest-auth-skip" @tap="skipGuestAuth">暂不授权(使用将无法保存数据)</text>
</view>
<button class="guest-auth-full" open-type="getPhoneNumber" :disabled="guestAuthLoading" @getphonenumber="onGuestPhone" />
<text class="guest-auth-later" @tap="skipGuestAuth">暂不,先浏览</text>
</view>
<!-- #endif -->
</view>
@@ -77,13 +72,13 @@ import { mpHeadPad } from '@/utils/mpHead.js'
const headPad = mpHeadPad()
import { onShow } from '@dcloudio/uni-app'
import { isLoggedIn, getTenants, getTid, setAuthTokens, setTid, setTenantName, getUser, setUser } from '@/utils/auth.js'
import { isLoggedIn, getTenants, getTid, setAuthTokens, setTenants, setTid, setTenantName, getUser, setUser } from '@/utils/auth.js'
import { switchTenant } from '@/api/auth.js'
import { fetchHome, fetchPublicModules, fetchPublicMenus } from '@/api/workspace.js'
import { openMenu } from '@/utils/nav.js'
import { getSubsystemByModule, goSubsystem } from '@/config/subsystems.js'
import { getGuestToken, setGuestToken } from '@/utils/guest.js'
import { guestMiniLogin } from '@/api/guest.js'
import { getGuestToken, setGuestToken, clearGuestToken } from '@/utils/guest.js'
import { guestMiniLogin, guestMerge } from '@/api/guest.js'
/* ==================== 游客手机号授权(免登录保存) ==================== */
@@ -137,6 +132,12 @@ async function onGuestPhone(e) {
appid = ''
}
const res = await guestMiniLogin({ code, phoneCode: detail.code, appid })
// 已注册手机号:后端已签发正式令牌,直接按登录处理(不再当作游客)
if (res && res.type === 'user') {
guestAuthVisible.value = false
await completeUserLogin(res)
return
}
if (!res || !res.token) throw new Error('未返回游客令牌')
setGuestToken(res.token)
guestAuthVisible.value = false
@@ -149,11 +150,74 @@ async function onGuestPhone(e) {
}
}
/** 手机号识别为正式账号:落登录态;多企业先选企业再进入 */
async function completeUserLogin(res) {
const list = Array.isArray(res.tenants) ? res.tenants : []
setAuthTokens(res.tokens || {})
if (res.need_choose_tenant && list.length > 1) {
askChooseTenant(list)
return
}
const t0 = list[0] || {}
setTenants(list)
if (t0.tid) {
setTid(t0.tid)
setTenantName(t0.tenant_name || '')
}
// 先游客后转正:企业落定后把游客数据移植到正式账号
await mergeGuestIfAny()
await load(true)
uni.showToast({ title: '已登录,欢迎回来', icon: 'none' })
}
/** 待选状态下必须选一个企业才能继续,关闭选择器后再次拉起 */
function askChooseTenant(list) {
uni.showActionSheet({
itemList: list.map((t) => t.tenant_name || `企业${t.tid}`),
success: async ({ tapIndex }) => {
const tenant = list[tapIndex]
if (!tenant) return
uni.showLoading({ title: '登录中...', mask: true })
try {
const sw = await switchTenant({ tid: tenant.tid })
if (!sw || !sw.tokens) throw new Error((sw && sw.msg) || '进入企业失败')
setAuthTokens(sw.tokens)
setTenants(list)
setTid(tenant.tid)
setTenantName(tenant.tenant_name || '')
// 先游客后转正:选定企业后移植游客数据
await mergeGuestIfAny()
await load(true)
uni.showToast({ title: '已登录,欢迎回来', icon: 'none' })
} catch (e) {
uni.showToast({ title: (e && e.message) || '进入失败,请重试', icon: 'none' })
} finally {
uni.hideLoading()
}
},
fail: () => {
uni.showToast({ title: '请选择要进入的企业', icon: 'none' })
setTimeout(() => askChooseTenant(list), 800)
}
})
}
function skipGuestAuth() {
guestAuthDismissed.value = true
guestAuthVisible.value = false
}
/** 存有游客令牌时静默移植数据(登录链路已带正式令牌);失败保留令牌下次登录重试 */
async function mergeGuestIfAny() {
if (!getGuestToken()) return
try {
await guestMerge()
clearGuestToken()
} catch (e) {
console.warn('[nav] guest merge failed:', e)
}
}
/** 模块 code → 子系统 slug → 进入其概览页;未注册返回 false */
function goSubsystemByModule(code) {
const sub = getSubsystemByModule(code)
@@ -273,7 +337,6 @@ async function load(force = false) {
const data = await fetchHome(force)
user.value = data.user || {}
tenants.value = getTenants()
currentTid.value = getTid()
modules.value = Array.isArray(data.modules) ? data.modules : []
menus.value = Array.isArray(data.menus) ? data.menus : []
loadError.value = ''
@@ -356,43 +419,6 @@ function goLogin(name = '') {
uni.navigateTo({ url: '/pages/login/login' })
}
function openTenantPicker() {
if (tenants.value.length < 2) return
tenantPickerVisible.value = true
}
function closeTenantPicker() {
if (!switchingTenant.value) tenantPickerVisible.value = false
}
async function changeTenant(item) {
const tid = Number(item && item.tid)
if (!tid || tid === currentTid.value || switchingTenant.value) {
tenantPickerVisible.value = false
return
}
switchingTenant.value = true
uni.showLoading({ title: '正在切换...', mask: true })
try {
const result = await switchTenant({ tid })
if (!result || !result.tokens) throw new Error((result && result.msg) || '切换公司失败')
setAuthTokens(result.tokens)
setTid(tid)
const tenantName = item.tenant_name || `企业${tid}`
setTenantName(tenantName)
user.value = Object.assign({}, user.value, { tenant: tenantName, tid })
currentTid.value = tid
tenantPickerVisible.value = false
await load(true)
uni.showToast({ title: '已切换公司', icon: 'success' })
} catch (err) {
uni.showToast({ title: (err && err.message) || '切换公司失败', icon: 'none' })
} finally {
uni.hideLoading()
switchingTenant.value = false
}
}
/* ==================== 生命周期 ==================== */
onMounted(() => {
@@ -627,7 +653,7 @@ onShow(() => {
height: 48rpx;
}
/* ============ 游客授权引导弹窗 ============ */
/* ============ 游客无感授权层(无弹窗,首次点按即拉起微信原生确认) ============ */
.guest-auth-mask {
position: fixed;
left: 0;
@@ -635,58 +661,35 @@ onShow(() => {
top: 0;
bottom: 0;
z-index: 99;
background: rgba(15, 23, 42, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 0 56rpx;
}
.guest-auth-card {
.guest-auth-full {
position: absolute;
left: 0;
top: 0;
width: 100%;
max-width: 560rpx;
background: #ffffff;
border-radius: 28rpx;
padding: 44rpx 36rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
box-shadow: 0 24rpx 64rpx rgba(15, 23, 42, 0.22);
}
.guest-auth-title {
font-size: 34rpx;
font-weight: 700;
color: #17345f;
}
.guest-auth-desc {
font-size: 24rpx;
line-height: 1.65;
color: #64748b;
text-align: left;
}
.guest-auth-btn {
width: 100%;
margin-top: 8rpx;
background: linear-gradient(145deg, #69a5ff, #3478f6);
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
border-radius: 44rpx;
line-height: 2.6;
height: 100%;
padding: 0;
margin: 0;
border: none;
border-radius: 0;
background: transparent;
opacity: 0;
&[disabled] {
opacity: 0.6;
&::after {
border: none;
}
}
.guest-auth-skip {
font-size: 22rpx;
.guest-auth-later {
position: absolute;
left: 0;
right: 0;
bottom: 64rpx;
z-index: 100;
text-align: center;
font-size: 24rpx;
color: #94a3b8;
padding: 8rpx 16rpx;
padding: 16rpx;
}
</style>