Просмотр исходного кода

fix: allow anonymous activity detail browsing

Developer 1 неделя назад
Родитель
Сommit
36cafb0b22

+ 2 - 1
common/composables/useGlobalShare.js

@@ -1,11 +1,12 @@
 import { ref } from 'vue';
 import { onLoad } from '@dcloudio/uni-app';
 import { useUserStore } from '@/common/stores/user';
+import { hasValidSession } from '@/common/utils/auth-session.js';
 const userStore = useUserStore();
 
 export function useGlobalShare() {
 	const isLogin = () => {
-		if(uni.getStorageSync('token')){
+		if(hasValidSession()){
 			return true
 		}else {
 			uni.showModal({

+ 5 - 0
common/utils/auth-session.js

@@ -0,0 +1,5 @@
+export const hasValidSession = (storage = uni, now = Date.now()) => {
+	const token = storage.getStorageSync('token')
+	const expireTime = Number(storage.getStorageSync('expaireTime'))
+	return Boolean(token && expireTime && now <= expireTime)
+}

+ 1 - 10
components/pages/nonprofitActivety/index.vue

@@ -1,5 +1,5 @@
 <template>
-	<view class="nonprofit-activety" @click.self="handleDetail(item)" v-if="item" :style="{'margin-top':index===0?0:'20rpx'}">
+	<view class="nonprofit-activety" @click="handleDetail(item)" v-if="item" :style="{'margin-top':index===0?0:'20rpx'}">
 		<view class="na-top adf">
 			<view class="na-top-left">
 				<image :src="item.coverFile"></image>
@@ -45,10 +45,6 @@
 			default:0
 		}
 	})
-	import { useUserStore } from '@/common/stores/user';
-	const userStore = useUserStore();
-	import { useGlobalShare } from '@/common/composables/useGlobalShare';
-	const { isLogin } = useGlobalShare();
 	const statusCfg = ref({
         '-1': '已取消',
 		0:'未开始',
@@ -68,15 +64,10 @@
 	})
 
 	const handleDetail = item => {
-		if(!isLogin()) return
 		uni.navigateTo({
 			url:'/pagesHome/activityDetail?id='+item.id
 		})
 	}
-
-	const toApply = () => {
-		userStore.openLoginModal();
-	}
 </script>
 
 <style scoped lang="scss">

+ 75 - 0
docs/superpowers/plans/2026-10-15-anonymous-activity-detail.md

@@ -0,0 +1,75 @@
+# Anonymous Activity Detail Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Allow anonymous users to enter and browse mini-program activity details, requiring login only when they invoke identity-dependent actions.
+
+**Architecture:** The backend detail endpoint accepts an optional user and the service treats a missing user as anonymous. The mini-program removes entry-time gates and uses one reusable action guard for detail interactions.
+
+**Tech Stack:** Java 8, Spring MVC, MyBatis, JUnit 4, Vue 3/uni-app, Node test runner
+
+**Spec:** `docs/superpowers/specs/2026-10-15-anonymous-activity-detail-design.md`
+
+## Global Constraints
+
+- Anonymous users must be able to load, scroll, expand, and leave activity details without a login prompt.
+- Identity-dependent actions must stop and open the existing login flow when no valid session exists.
+- Existing authenticated permissions and write-operation authentication remain unchanged.
+
+## Review Focus
+
+- Missing token: detail data loads and no automatic login modal opens.
+- Expired local session: action guard treats it as logged out even if a stale token exists.
+- Valid token: action executes without an extra prompt.
+- Anonymous detail SQL: null user ID does not accidentally match signup rows.
+- Login completion: detail is refreshed to show the signed-in user's state.
+
+---
+
+### Task 1: Anonymous backend activity details
+
+**Files:**
+- Modify: `../happytree/happytree-app/src/main/java/com/ringzle/controller/core/AppActivityController.java`
+- Modify: `../happytree/happytree-sys/src/main/java/com/ringzle/modules/core/service/impl/ActivityServiceImpl.java`
+- Test: `../happytree/happytree-sys/src/test/java/com/ringzle/modules/core/service/impl/ActivityServiceAnonymousDetailTest.java`
+
+**Interfaces:**
+- Consumes: optional `AppUserEntity user` from the existing argument resolver.
+- Produces: `ActivityService.getAppInfoById(Long, AppUserEntity)` supports `user == null`.
+
+- [ ] Write service tests proving anonymous lookup passes null user ID and skips user-specific permission queries, while authenticated lookup retains permission checks.
+- [ ] Run the targeted tests and verify the anonymous case fails with a null dereference.
+- [ ] Remove the controller's mandatory-login annotation and make the service null-safe.
+- [ ] Run the targeted test and full `happytree-sys` suite in Docker Maven.
+- [ ] Commit the backend change.
+
+### Task 2: Anonymous mini-program browsing and guarded actions
+
+**Files:**
+- Create: `common/utils/auth-session.js`
+- Modify: `components/pages/nonprofitActivety/index.vue`
+- Modify: `pagesHome/activityDetail.vue`
+- Test: `tests/activity-detail-login-gate.test.js`
+
+**Interfaces:**
+- Consumes: `hasValidSession()` based on token and expiration storage.
+- Produces: activity-card navigation without login and `requireLogin(action)` for identity-dependent detail interactions.
+
+- [ ] Write behavior tests for anonymous card navigation, passive detail loading, guarded actions, expired sessions, valid sessions, and post-login refresh.
+- [ ] Run the new test and verify it fails for the existing entry-time gates.
+- [ ] Implement the session helper, remove entry-time prompts, and wrap identity-dependent actions with the reusable guard.
+- [ ] Run the new test and all mini-program Node tests.
+- [ ] Commit the mini-program change.
+
+### Task 3: Cross-repository verification
+
+**Files:**
+- Verify only; no planned production changes.
+
+**Interfaces:**
+- Consumes: anonymous backend details and guarded mini-program actions from Tasks 1 and 2.
+- Produces: verified build/test evidence for handoff.
+
+- [ ] Compile the backend app module with Docker Maven and run all backend tests.
+- [ ] Run all mini-program Node tests and inspect both repository diffs.
+- [ ] Record any deviations and complete final review.

+ 21 - 0
docs/superpowers/specs/2026-10-15-anonymous-activity-detail-design.md

@@ -0,0 +1,21 @@
+# 小程序活动详情匿名浏览设计
+
+## 目标
+
+未登录用户从首页点击活动时直接进入并完整浏览活动详情,不在进入详情前要求登录。用户主动使用需要身份的功能时,才提示登录。
+
+## 交互规则
+
+- 首页活动卡片始终可以进入详情。
+- 活动详情的加载、滚动、轮播、展开富文本和返回不触发登录。
+- 查看报名人员、报名、取消报名、签到、填写公益档案和分享入口需要登录;未登录时显示现有登录确认框并中止原操作。
+- 登录成功后重新获取活动详情,恢复当前用户的报名状态和专享权限。
+- 已登录用户的现有功能和权限校验保持不变。
+
+## 接口设计
+
+`GET /app/core/activity/{id}` 改为可选登录:请求带有效 token 时注入用户并执行现有用户权限与报名状态逻辑;未带 token 时以 `userId = null` 查询公开详情,报名状态返回 `0`,不执行基于用户的专享券或渠道权限判断。
+
+## 安全边界
+
+匿名接口只提供原活动详情接口已有的展示字段,不开放报名、签到、取消等写操作。所有写操作继续由带 `@AppLogin` 的接口保护。

+ 19 - 39
pagesHome/activityDetail.vue

@@ -228,7 +228,9 @@
 	import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' 
 	const { proxy } = getCurrentInstance()
 	import { useUserStore } from '@/common/stores/user';
+	import { useGlobalShare } from '@/common/composables/useGlobalShare';
 	const userStore = useUserStore();
+	const { isLogin } = useGlobalShare();
 	
 	const id = ref('')
 	const activityInfo = ref(null)
@@ -311,14 +313,19 @@
 		})
 	}
 	
-	const handleReviewMembers = () => {
+	const requireLogin = action => {
+		if(!isLogin()) return false
+		return action()
+	}
+
+	const handleReviewMembers = () => requireLogin(() => {
 		uni.navigateTo({
 			url:'/pagesHome/recruitsNumber?activityId='+activityInfo.value?.id+'&recruitmentMax='+activityInfo.value?.recruitmentMax+'&recruitmentMin='+activityInfo.value?.recruitmentMin
 		})
-	}
+	})
 	
 	//立即报名
-	const handleApply = async () => {
+	const handleApply = () => requireLogin(async () => {
 		const memberInfoRaw = uni.getStorageSync('familyMemberInfo') || uni.getStorageSync('userInfo');
 		const memberInfo = memberInfoRaw ? (typeof memberInfoRaw === 'string' ? JSON.parse(memberInfoRaw) : memberInfoRaw) : {};
 		const level = memberInfo?.userLevel ?? 0;
@@ -335,25 +342,25 @@
 			}
 		}
 		uni.navigateTo({ url })
-	}
+	})
 	//取消报名
-	const handleCancel = () => {
+	const handleCancel = () => requireLogin(() => {
 		uni.reLaunch({
 			url:'/pages/nonprofit?type=1'
 		})
-	}
+	})
 	//我要签到
-	const handleSignup = () => {
+	const handleSignup = () => requireLogin(() => {
 		uni.reLaunch({
 			url:'/pages/nonprofit?type=2'
 		})
-	}
+	})
 	//填写公益档案
-	const handleFill = () => {
+	const handleFill = () => requireLogin(() => {
 		uni.reLaunch({
 			url:'/pages/nonprofit?type=3'
 		})
-	}
+	})
 	
 	const getUserLoveTicket = () => {
 		return new Promise(resolve=>{
@@ -392,9 +399,9 @@
 		};
 	})
 	
-	const showShareOptions = () => {
+	const showShareOptions = () => requireLogin(() => {
 		share.value = true;
-	}
+	})
 	
 	// 分享到朋友圈(旧逻辑)
 	const shareToTimeline = () => {
@@ -735,33 +742,6 @@
 	onLoad(options=>{
 		id.value = options?.id||options?.shareADId||'';
 		shareADId.value = options?.shareADId||'';
-		if(!uni.getStorageSync('token')||!uni.getStorageSync('expaireTime')||(Date.parse(new Date())>(uni.getStorageSync('expaireTime')||0))){
-			uni.showModal({
-				title:'温馨提示',
-				content:'您当前登录已失效或未登录过,是否立即登录?',
-				success: (res) => {
-					if(res.confirm){
-						userStore.openLoginModal()
-					}else if(res.cancel){
-						uni.showModal({
-							title:'温馨提示',
-							content:'您已取消登录,当前活动详情页面不可查看',
-							showCancel:false,
-							confirmText:'返回首页',
-							success: (res) => {
-								if(res.confirm){
-									uni.reLaunch({
-										url:'/pages/home'
-									})
-								}
-							}
-						})
-					}
-				}
-			})
-			return
-		}
-		
 		if(id.value) {
 			getActivityDetail(id.value)
 		}

+ 165 - 0
tests/activity-detail-login-gate.test.js

@@ -0,0 +1,165 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+import vm from 'node:vm'
+
+function scriptSetup (file) {
+  return fs.readFileSync(path.resolve(file), 'utf8')
+    .match(/<script setup[^>]*>([\s\S]*?)<\/script>/)[1]
+    .replace(/^\s*import\s+.*$/gm, '')
+}
+
+function mountActivityCard () {
+  const navigations = []
+  const context = {
+    ref: value => ({ value }),
+    computed: getter => ({ get value () { return getter() } }),
+    defineProps: () => ({ item: { id: 'activity-1' }, index: 0 }),
+    useUserStore: () => ({}),
+    useGlobalShare: () => ({ isLogin: () => false }),
+    uni: { navigateTo: options => navigations.push(options) }
+  }
+  vm.runInNewContext(
+    scriptSetup('components/pages/nonprofitActivety/index.vue') +
+      '\nglobalThis.__card = { handleDetail }',
+    context
+  )
+  return { card: context.__card, navigations }
+}
+
+function mountActivityDetail ({ loggedIn = false, storage = {} } = {}) {
+  const lifecycle = {}
+  const navigations = []
+  const apiRequests = []
+  const modals = []
+  let loginChecks = 0
+  const userStore = {
+    token: '',
+    openLoginModal () {}
+  }
+  const context = {
+    ref: value => ({ value }),
+    reactive: value => value,
+    onMounted: () => {},
+    nextTick: callback => callback(),
+    watch: (getter, callback) => { lifecycle.watchToken = callback },
+    onLoad: callback => { lifecycle.load = callback },
+    onShareAppMessage: () => {},
+    onShareTimeline: () => {},
+    getCurrentInstance: () => ({
+      proxy: {
+        $api: {
+          get: (url) => {
+            apiRequests.push(url)
+            return new Promise(() => {})
+          }
+        },
+        $showModal: () => {},
+        $showToast: () => {},
+        $refs: {}
+      }
+    }),
+    useUserStore: () => userStore,
+    useGlobalShare: () => ({
+      isLogin: () => {
+        loginChecks++
+        return loggedIn
+      }
+    }),
+    uni: {
+      getStorageSync: key => storage[key] || '',
+      showModal: options => modals.push(options),
+      showToast: () => {},
+      navigateTo: options => navigations.push(options),
+      reLaunch: options => navigations.push(options)
+    },
+    setTimeout: callback => callback(),
+    Date,
+    JSON,
+    console
+  }
+
+  vm.runInNewContext(
+    scriptSetup('pagesHome/activityDetail.vue') +
+      '\nglobalThis.__detail = { handleReviewMembers, handleApply, handleCancel, handleSignup, handleFill, showShareOptions, share }',
+    context
+  )
+  return { detail: context.__detail, lifecycle, navigations, apiRequests, modals, getLoginChecks: () => loginChecks }
+}
+
+async function loadAuthSession () {
+  const source = fs.readFileSync(path.resolve('common/utils/auth-session.js'), 'utf8')
+  return import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`)
+}
+
+test('未登录用户点击首页活动直接进入详情', () => {
+  const { card, navigations } = mountActivityCard()
+
+  card.handleDetail({ id: 'activity-1' })
+
+  assert.equal(navigations.length, 1)
+  assert.equal(navigations[0].url, '/pagesHome/activityDetail?id=activity-1')
+})
+
+test('未登录进入详情时直接加载活动且不自动弹登录框', () => {
+  const { lifecycle, apiRequests, modals } = mountActivityDetail()
+
+  lifecycle.load({ id: 'activity-1' })
+
+  assert.deepEqual(apiRequests, ['/core/activity/activity-1'])
+  assert.equal(modals.length, 0)
+})
+
+test('未登录点击身份相关操作时中止操作并触发登录判断', () => {
+  const { detail, navigations, getLoginChecks } = mountActivityDetail({ loggedIn: false })
+
+  detail.handleReviewMembers()
+  detail.handleApply()
+  detail.handleCancel()
+  detail.handleSignup()
+  detail.handleFill()
+  detail.showShareOptions()
+
+  assert.equal(navigations.length, 0)
+  assert.equal(detail.share.value, false)
+  assert.equal(getLoginChecks(), 6)
+})
+
+test('已登录点击身份相关操作时继续执行原操作', async () => {
+  const { detail, navigations, getLoginChecks } = mountActivityDetail({ loggedIn: true })
+
+  detail.handleReviewMembers()
+  await detail.handleApply()
+  detail.handleCancel()
+  detail.handleSignup()
+  detail.handleFill()
+  detail.showShareOptions()
+
+  assert.equal(navigations.length, 5)
+  assert.equal(detail.share.value, true)
+  assert.equal(getLoginChecks(), 6)
+})
+
+test('登录完成后重新获取当前活动详情', () => {
+  const { lifecycle, apiRequests } = mountActivityDetail()
+  lifecycle.load({ id: 'activity-1' })
+
+  lifecycle.watchToken('', 'old-token')
+  lifecycle.watchToken('new-token', '')
+
+  assert.deepEqual(apiRequests, [
+    '/core/activity/activity-1',
+    '/core/activity/activity-1'
+  ])
+})
+
+test('登录态同时校验 token 和过期时间', async () => {
+  const { hasValidSession } = await loadAuthSession()
+  const now = 1000
+  const storage = values => ({ getStorageSync: key => values[key] || '' })
+
+  assert.equal(hasValidSession(storage({ token: 'token', expaireTime: 1001 }), now), true)
+  assert.equal(hasValidSession(storage({ token: 'token', expaireTime: 999 }), now), false)
+  assert.equal(hasValidSession(storage({ expaireTime: 1001 }), now), false)
+})

+ 2 - 1
tests/activity-signup-member-balance.test.js

@@ -8,7 +8,7 @@ const detailFile = path.resolve('pagesHome/activityDetail.vue')
 
 function loadHandleApply ({ activityLimit, valueLimit, parentLoveValue, couponIds = [] }) {
   const source = fs.readFileSync(detailFile, 'utf8')
-  const start = source.indexOf('\tconst handleApply = async () => {')
+  const start = source.indexOf('\tconst handleApply = () => requireLogin(async () => {')
   const end = source.indexOf('\n\t//取消报名', start)
   assert.notEqual(start, -1, '应找到立即报名方法')
   assert.notEqual(end, -1, '应找到立即报名方法结束位置')
@@ -25,6 +25,7 @@ function loadHandleApply ({ activityLimit, valueLimit, parentLoveValue, couponId
       }
     },
     fail,
+    requireLogin: action => action(),
     getUserLoveTicket: () => Promise.resolve({ loveValue: parentLoveValue, couponIds }),
     proxy: { $showModal: () => {} },
     uni: {