Переглянути джерело

feat(auth): 跨小程序单点登录 sso_ticket 换票与双向免登

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
liaoxg 3 днів тому
батько
коміт
4d984692c8

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10952,6 +10952,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         createTaskReminderConfigTable();
         addMissingColumnsToRecommendationLog();
         createDietFamilySelectedIngredientsTable();
+        createSsoTicketTable();
 
         // 迁移341: BASIC 体验会员 features JSON 补 referral_commission(迁移315 未包含,前端对比表已显示可参与)
         try {
@@ -11859,6 +11860,29 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    // 迁移359: 跨小程序单点登录一次性票据表(cfc ↔ train 双向免登)
+    // 详见 schema.sql。此处自愈建表,避免 schema.sql 建表语句因故静默失败导致登录报错。
+    private void createSsoTicketTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS sso_ticket (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "ticket VARCHAR(64) NOT NULL COMMENT '一次性票据(SecureRandom 生成)'," +
+                    "source_system VARCHAR(16) NOT NULL COMMENT '签发方系统: cfc / train'," +
+                    "target_system VARCHAR(16) NOT NULL COMMENT '目标系统: cfc / train'," +
+                    "source_user_id BIGINT NOT NULL COMMENT '签发方用户ID(cfc=users.id / train=train_user.id)'," +
+                    "cfc_user_id BIGINT NOT NULL COMMENT 'cfc 统一会员ID(users.id),跨系统换票依据'," +
+                    "used TINYINT NOT NULL DEFAULT 0 COMMENT '是否已使用: 0=未用 1=已用'," +
+                    "expire_at DATETIME NOT NULL COMMENT '过期时间'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "UNIQUE KEY uk_ticket (ticket)," +
+                    "INDEX idx_expire (expire_at)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跨小程序单点登录一次性票据'");
+            log.info("迁移359: 已创建 sso_ticket 表");
+        } catch (Exception e) {
+            log.warn("迁移359: 创建 sso_ticket 表失败: {}", e.getMessage());
+        }
+    }
+
     // 迁移355: product_recommendation_log 补齐缺失列
     // 旧库该表由更早版本的建表语句创建(CREATE TABLE IF NOT EXISTS 不会补列),
     // 缺 family_id/was_clicked/was_purchased/clicked_at/purchased_at/updated_at,

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -34,6 +34,7 @@ private static final String[] PUBLIC_PATHS = {
         "/api/auth/phone-login",
         "/api/auth/wechat-phone-login",
         "/api/auth/silent-login",
+        "/api/auth/sso-login",
         "/api/auth/auto-login",
         "/api/auth/register-with-idcard",
         "/api/auth/wechat-login",

+ 55 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/auth/AuthController.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.dto.RegisterWithIdCardDTO;
 import com.etotem.cfc.dto.RegisterWithInviteCodeDTO;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.SsoService;
 import com.etotem.cfc.service.UserService;
 import com.etotem.cfc.service.VerificationCodeService;
 import io.jsonwebtoken.Claims;
@@ -39,6 +40,9 @@ public class AuthController {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private SsoService ssoService;
+
     /**
      * 发送验证码
      */
@@ -336,4 +340,55 @@ public class AuthController {
         }
     }
 
+    /**
+     * 跨小程序单点登录 - 签发一次性票据(需登录)。
+     * 请求体: { "target": "train" };返回 { ticket, target }。
+     * 前端拿到 ticket 后通过 uni.navigateToMiniProgram 跳转到目标小程序的落地页。
+     */
+    @Operation(summary = "SSO 签发一次性票据")
+    @PostMapping("/sso-ticket")
+    public Result<Map<String, Object>> ssoTicket(@RequestBody Map<String, String> body,
+                                                 @RequestAttribute("userId") Long userId) {
+        if (userId == null || userId <= 0) {
+            return Result.error(401, "请先登录");
+        }
+        String target = body != null ? body.get("target") : null;
+        if (!SsoService.SYS_TRAIN.equals(target)) {
+            return Result.error("不支持的目标系统");
+        }
+        try {
+            String ticket = ssoService.issueTicket(SsoService.SYS_CFC, target, userId, userId);
+            Map<String, Object> data = new HashMap<>();
+            data.put("ticket", ticket);
+            data.put("target", target);
+            return Result.success(data);
+        } catch (Exception e) {
+            log.error("签发 SSO 票据失败: userId={}", userId, e);
+            return Result.error("签发票据失败,请稍后重试");
+        }
+    }
+
+    /**
+     * 跨小程序单点登录 - 消费一次性票据换取 cfc 登录态(公开接口)。
+     * 请求体: { "ticket": "..." }。
+     */
+    @Operation(summary = "SSO 票据换取登录态")
+    @PostMapping("/sso-login")
+    public Result<LoginResultDTO> ssoLogin(@RequestBody Map<String, String> body) {
+        String ticket = body != null ? body.get("ticket") : null;
+        if (ticket == null || ticket.trim().isEmpty()) {
+            return Result.error("票据不能为空");
+        }
+        Long cfcUserId = ssoService.consumeTicket(ticket, SsoService.SYS_CFC);
+        if (cfcUserId == null) {
+            return Result.error("票据无效或已过期,请重新进入");
+        }
+        try {
+            LoginResultDTO result = userService.ssoLogin(cfcUserId);
+            return Result.success(result);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
 }

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SsoTicket.java

@@ -0,0 +1,44 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 跨小程序单点登录一次性票据(cfc ↔ train 双向免登)。
+ * cfc 与 train 共用本库(zxyj),签发方写入,目标方原子占用(used 0→1)。
+ */
+@Data
+@TableName("sso_ticket")
+public class SsoTicket implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 一次性票据(SecureRandom 生成) */
+    private String ticket;
+
+    /** 签发方系统: cfc / train */
+    private String sourceSystem;
+
+    /** 目标系统: cfc / train */
+    private String targetSystem;
+
+    /** 签发方用户ID(cfc=users.id / train=train_user.id) */
+    private Long sourceUserId;
+
+    /** cfc 统一会员ID(users.id),跨系统换票依据 */
+    private Long cfcUserId;
+
+    /** 是否已使用: 0=未用 1=已用 */
+    private Integer used;
+
+    /** 过期时间 */
+    private Date expireAt;
+
+    private Date createdAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/SsoTicketMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.SsoTicket;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SsoTicketMapper extends BaseMapper<SsoTicket> {
+}

+ 95 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/SsoService.java

@@ -0,0 +1,95 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.etotem.cfc.entity.SsoTicket;
+import com.etotem.cfc.mapper.SsoTicketMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.security.SecureRandom;
+import java.util.Date;
+
+/**
+ * 跨小程序单点登录一次性票据服务(cfc ↔ train 双向免登)。
+ * cfc 与 train 共用本库(zxyj),ticket 由签发方写入,目标方原子占用(used 0→1)。
+ */
+@Slf4j
+@Service
+public class SsoService {
+
+    /** 系统标识:cfc */
+    public static final String SYS_CFC = "cfc";
+    /** 系统标识:train */
+    public static final String SYS_TRAIN = "train";
+
+    private static final SecureRandom RANDOM = new SecureRandom();
+
+    @Resource
+    private SsoTicketMapper ssoTicketMapper;
+
+    /** 票据有效期(毫秒),默认 5 分钟 */
+    @Value("${sso.ticket-ttl-ms:300000}")
+    private long ticketTtlMs;
+
+    /**
+     * 签发一次性票据。
+     *
+     * @param sourceSystem 签发方系统标识(cfc / train)
+     * @param targetSystem 目标系统标识(cfc / train)
+     * @param sourceUserId 签发方用户ID(cfc=users.id / train=train_user.id)
+     * @param cfcUserId    cfc 统一会员ID(users.id),跨系统换票依据
+     * @return 票据字符串(64 位十六进制)
+     */
+    public String issueTicket(String sourceSystem, String targetSystem, Long sourceUserId, Long cfcUserId) {
+        SsoTicket t = new SsoTicket();
+        t.setTicket(generateTicket());
+        t.setSourceSystem(sourceSystem);
+        t.setTargetSystem(targetSystem);
+        t.setSourceUserId(sourceUserId);
+        t.setCfcUserId(cfcUserId);
+        t.setUsed(0);
+        t.setExpireAt(new Date(System.currentTimeMillis() + ticketTtlMs));
+        t.setCreatedAt(new Date());
+        ssoTicketMapper.insert(t);
+        return t.getTicket();
+    }
+
+    /**
+     * 原子消费票据:仅当「票据未使用 + 未过期 + 目标系统匹配」时置 used=1。
+     * 并发/重放场景下若多请求同时命中,只有 update 影响行数=1 的那一个成功。
+     *
+     * @return 成功返回 cfc_user_id;无效/已用/过期/目标不符返回 null
+     */
+    public Long consumeTicket(String ticket, String targetSystem) {
+        if (ticket == null || ticket.trim().isEmpty()) {
+            return null;
+        }
+        int updated = ssoTicketMapper.update(null, new LambdaUpdateWrapper<SsoTicket>()
+                .eq(SsoTicket::getTicket, ticket)
+                .eq(SsoTicket::getTargetSystem, targetSystem)
+                .eq(SsoTicket::getUsed, 0)
+                .gt(SsoTicket::getExpireAt, new Date())
+                .set(SsoTicket::getUsed, 1));
+        if (updated != 1) {
+            log.warn("SSO 票据消费失败(无效/已用/过期/目标不符): target={}", targetSystem);
+            return null;
+        }
+        SsoTicket t = ssoTicketMapper.selectOne(new LambdaQueryWrapper<SsoTicket>()
+                .eq(SsoTicket::getTicket, ticket));
+        return t != null ? t.getCfcUserId() : null;
+    }
+
+    private String generateTicket() {
+        byte[] bytes = new byte[32];
+        RANDOM.nextBytes(bytes);
+        StringBuilder sb = new StringBuilder(64);
+        for (byte b : bytes) {
+            sb.append(Character.forDigit((b >> 4) & 0xF, 16));
+            sb.append(Character.forDigit(b & 0xF, 16));
+        }
+        return sb.toString();
+    }
+}

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -655,6 +655,45 @@ private FamilyInvitationService familyInvitationService;
         return result;
     }
 
+    /**
+     * 跨小程序单点登录(SSO):凭一次性票据换取的 cfc_user_id 直接建立登录态。
+     *
+     * <p>身份可信度来自票据本身:短时效(默认 5 分钟)+ 一次性(消费即置 used=1)+ 签发方鉴权
+     * (签发时必须携带有效 cfc 登录态)。票据只在本服务与 train 共享的库中传递,不依赖 wx.login。
+     *
+     * @param cfcUserId cfc 统一会员ID(users.id)
+     */
+    public LoginResultDTO ssoLogin(Long cfcUserId) {
+        if (cfcUserId == null) {
+            throw new RuntimeException("SSO 票据无效");
+        }
+        User user = userMapper.selectById(cfcUserId);
+        if (user == null) {
+            throw new RuntimeException("用户不存在");
+        }
+        // 管理员不能登录小程序
+        if ("admin".equals(user.getRole())) {
+            throw new RuntimeException("该账号不允许登录小程序");
+        }
+        ensureUserHasFamily(user);
+
+        String token = jwtConfig.generateToken(user.getId(), user.getRole());
+        LoginResultDTO result = new LoginResultDTO();
+        result.setToken(token);
+        result.setUserId(user.getId());
+        result.setRole(user.getRole());
+        result.setRoles(user.getRoles());
+        result.setFamilyId(user.getFamilyId());
+        result.setNickname(user.getNickname());
+        result.setPhone(user.getPhone());
+        result.setIsNewUser(false);
+        result.setOpenid(user.getOpenid());
+        result.setNeedsFamily(user.getFamilyId() == null || user.getFamilyId() == 0L);
+        populateTeacherFields(result, user);
+        populateActivityProviderFields(result, user);
+        return result;
+    }
+
     /**
      * 获取用户所有角色列表
      */

+ 15 - 0
cfc-backend/src/main/resources/schema.sql

@@ -6172,3 +6172,18 @@ CREATE TABLE IF NOT EXISTS fat_goal (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     INDEX idx_fat_goal_member (member_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-减脂目标';
+-- 迁移359: 跨小程序单点登录一次性票据(cfc ↔ train 双向免登)
+-- cfc 与 train 共用本库(zxyj)。签发方写入 ticket,目标方原子占用(used 0→1)后按 cfc_user_id 建立登录态。
+CREATE TABLE IF NOT EXISTS sso_ticket (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    ticket VARCHAR(64) NOT NULL COMMENT '一次性票据(SecureRandom 生成)',
+    source_system VARCHAR(16) NOT NULL COMMENT '签发方系统: cfc / train',
+    target_system VARCHAR(16) NOT NULL COMMENT '目标系统: cfc / train',
+    source_user_id BIGINT NOT NULL COMMENT '签发方用户ID(cfc=users.id / train=train_user.id)',
+    cfc_user_id BIGINT NOT NULL COMMENT 'cfc 统一会员ID(users.id),跨系统换票依据',
+    used TINYINT NOT NULL DEFAULT 0 COMMENT '是否已使用: 0=未用 1=已用',
+    expire_at DATETIME NOT NULL COMMENT '过期时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    UNIQUE KEY uk_ticket (ticket),
+    INDEX idx_expire (expire_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跨小程序单点登录一次性票据';

+ 30 - 2
cfc-frontend/App.vue

@@ -1,7 +1,7 @@
 <script>
 import config from '@/config.js'
 import Vue from 'vue'
-import { checkUserFamily, verifyToken, bindReferralIfNotSelf, silentRelogin } from './utils/api.js'
+import { checkUserFamily, verifyToken, bindReferralIfNotSelf, silentRelogin, ssoLogin } from './utils/api.js'
 Vue.prototype.$config = config
 
 export default {
@@ -19,7 +19,7 @@ export default {
     // inviteCode=推荐码(文章/活动/商品分享)| refCode=推广码 | token/familyInviteCode=家庭邀请令牌 | id=内容详情
     var hasDeepLink = options && options.query && (
       options.query.inviteCode || options.query.refCode || options.query.token ||
-      options.query.familyInviteCode || options.query.id
+      options.query.familyInviteCode || options.query.id || options.query.ssoTicket
     )
     // 冷启动:跳转到 splash 页展示过渡动画,2秒后自动进入首页
     if (options.scene !== 1047 && options.scene !== 1048 && options.scene !== 1049 && !hasDeepLink) {
@@ -27,6 +27,8 @@ export default {
       uni.redirectTo({ url: '/pages/splash/index' })
       return
     }
+    // 跨小程序单点登录:携带 ssoTicket 进入 → 换取本地登录态
+    this.handleSsoLogin(options)
     // 处理小程序码场景值(从 wxacode 扫码进入,跳过 splash)
     this.handleScene(options)
   },
@@ -34,6 +36,8 @@ export default {
     console.log('App Show', options)
     // 热启动(含从分享卡重新进入):同样捕获分享参数
     this.captureReferralParams(options)
+    // 跨小程序单点登录:热启动携带 ssoTicket 进入同样处理
+    this.handleSsoLogin(options)
     // 每次 app 展示时也检查场景值(小程序码可能从冷启动后的 onShow 传入)
     this.handleScene(options)
     
@@ -167,6 +171,30 @@ bindReferralIfNotSelf(code).then(function(res) {
       // 同步 familyId 到 store(hasFamily getter 依赖;静默续期路径)
       this.$store.commit('setFamilyId', data.familyId)
     },
+    // 跨小程序单点登录:从 train 跳转进入时携带 ssoTicket → 换取本地登录态。
+    // 一次性票据(后端原子消费),用 _ssoHandledTicket 去重,避免 onLaunch/onShow 双处理。
+    handleSsoLogin(options) {
+      if (!options || !options.query) return
+      var ticket = options.query.ssoTicket
+      if (!ticket) return
+      if (this._ssoHandledTicket === ticket) return
+      this._ssoHandledTicket = ticket
+      var self = this
+      uni.showLoading({ title: '登录中...' })
+      ssoLogin(ticket).then(function(res) {
+        uni.hideLoading()
+        if (res && res.code === 200 && res.data) {
+          self.saveLoginInfo(res.data)
+          // 清空路由票据(防转发泄漏)并落地首页;index/index 会按角色重新分流
+          uni.reLaunch({ url: '/pages/index/index' })
+        } else {
+          uni.showToast({ title: (res && res.message) || '单点登录失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        uni.hideLoading()
+        uni.showToast({ title: (e && e.message) || '单点登录失败', icon: 'none' })
+      })
+    },
     // 处理小程序码场景值
     handleScene(options) {
       if (!options || !options.query) return

+ 35 - 0
cfc-frontend/pages/profile/index.vue

@@ -14,6 +14,11 @@
       </view>
       <ProfileHeader @avatar-click="goToEditInfo" />
       <ProfileMenu :role="role" @invite-generate="onInviteGenerate" />
+      <view class="sso-entry" @click="goTrain">
+        <text class="sso-entry-icon">🎓</text>
+        <text class="sso-entry-text">进入训练营</text>
+        <text class="sso-entry-arrow">›</text>
+      </view>
     </template>
   </view>
 </template>
@@ -22,6 +27,7 @@
 import ProfileHeader from '@/pages/profile/components/ProfileHeader.vue'
 import ProfileMenu from '@/pages/profile/components/ProfileMenu.vue'
 import { getReferralCode, extractReferralCode } from '../../utils/api.js'
+import { jumpToTrain } from '../../utils/sso.js'
 
 export default {
   components: {
@@ -107,6 +113,9 @@ export default {
         url: '/pages/user-edit/user-edit'
       })
     },
+    goTrain() {
+      jumpToTrain()
+    },
     onInviteGenerate(data) {
       this.shareData = data
       uni.showToast({
@@ -189,4 +198,30 @@ export default {
   font-size: 28rpx;
   color: #666;
 }
+/* 跨小程序单点登录入口 */
+.sso-entry {
+  display: flex;
+  align-items: center;
+  margin: 20rpx 30rpx 0;
+  padding: 28rpx 24rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
+}
+.sso-entry:active {
+  background: #f8fafc;
+}
+.sso-entry-icon {
+  font-size: 40rpx;
+  margin-right: 16rpx;
+}
+.sso-entry-text {
+  flex: 1;
+  font-size: 30rpx;
+  color: #333;
+}
+.sso-entry-arrow {
+  font-size: 40rpx;
+  color: #cbd5e1;
+}
 </style>

+ 9 - 0
cfc-frontend/utils/api.js

@@ -3385,3 +3385,12 @@ export const getVendorTeamCustomers = (params) =>
 
 export const bindVendorCustomer = (params) =>
   request('/api/admin/supply-team/bind-customer', 'POST', params)
+
+// ===================== 跨小程序单点登录(SSO:cfc ↔ train)=====================
+/** 签发跳转到 train 的一次性票据。返回 { ticket, target } */
+export const createSsoTicket = (target) =>
+  request('/api/auth/sso-ticket', 'POST', { target: target || 'train' })
+
+/** 用一次性票据换取登录态。返回登录信息(结构同 wechatLogin 的 data) */
+export const ssoLogin = (ticket) =>
+  request('/api/auth/sso-login', 'POST', { ticket })

+ 34 - 0
cfc-frontend/utils/sso.js

@@ -0,0 +1,34 @@
+// 跨小程序单点登录(SSO):从 cfc 跳转到训练营(train)小程序。
+// 复用方式:页面按钮 @click 调 jumpToTrain() 即可;后端已用一次性票据免二次微信授权。
+import { createSsoTicket } from './api.js'
+
+// 训练营小程序 AppID
+const TRAIN_APPID = 'wxcf0ed181fc3bce50'
+// 训练营落地页(App.onLaunch 会读取 query.ssoTicket 换票登录)
+const TRAIN_LANDING_PATH = 'pages/index/index'
+
+/** 已登录用户跳到训练营:签发一次性票据 → navigateToMiniProgram */
+export function jumpToTrain() {
+  if (!uni.getStorageSync('token')) {
+    uni.showToast({ title: '请先登录', icon: 'none' })
+    return
+  }
+  uni.showLoading({ title: '跳转中...' })
+  createSsoTicket('train').then(function(res) {
+    uni.hideLoading()
+    var ticket = res && res.data && res.data.ticket
+    if (!ticket) {
+      uni.showToast({ title: '签发失败,请稍后重试', icon: 'none' })
+      return
+    }
+    uni.navigateToMiniProgram({
+      appId: TRAIN_APPID,
+      path: TRAIN_LANDING_PATH + '?ssoTicket=' + encodeURIComponent(ticket),
+      fail: function() {
+        uni.showToast({ title: '跳转失败,请确认已关联训练营小程序', icon: 'none' })
+      }
+    })
+  }).catch(function() {
+    uni.hideLoading()
+  })
+}