瀏覽代碼

解决分享后没有重新获取授权的问题

haifeng.zhang 2 周之前
父節點
當前提交
5b53259c8c
共有 3 個文件被更改,包括 440 次插入8 次删除
  1. 354 0
      docs/api.md
  2. 6 0
      src/api/wecom/index.ts
  3. 80 8
      src/pages/home/index.vue

+ 354 - 0
docs/api.md

@@ -0,0 +1,354 @@
+# 接口文档
+
+## 一、app-business-service Token 认证接口
+
+### 1.1 获取 Token
+
+| 项目 | 说明 |
+|------|------|
+| URL | `POST /auth/getToken` |
+| Content-Type | `application/x-www-form-urlencoded` |
+| 是否需要Token | 否 |
+
+**请求参数**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| contactPerson | String | 是 | 联系人电话 |
+
+**请求示例**:
+```http
+POST /auth/getToken
+Content-Type: application/x-www-form-urlencoded
+
+contactPerson=13800001111
+```
+
+**成功响应**:
+```json
+{
+  "code": 200,
+  "data": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
+}
+```
+
+**失败响应**:
+```json
+{
+  "code": 10001,
+  "msg": "contactPerson不能为空"
+}
+```
+
+### 1.2 Token 校验机制
+
+所有接口(除 `/auth/getToken` 外)请求头必须携带:
+
+| Header | 值 | 说明 |
+|--------|---|------|
+| `token` | 获取到的 token 字符串 | 24小时有效,不自动续期 |
+
+**Token 失效响应**:
+```json
+{
+  "code": 10021,
+  "msg": "TOKEN失效"
+}
+```
+
+---
+
+## 二、两度模型 - 来源优先级配置
+
+基础路径:`/sourceFieldAuthority`,所有接口统一 POST
+
+### 2.1 新增
+
+| 项目 | 说明 |
+|------|------|
+| URL | `POST /sourceFieldAuthority/save` |
+| Content-Type | `application/json` |
+
+**请求参数**:
+```json
+{
+  "entityType": "PERSON",
+  "modelField": "phone",
+  "sourceTable": "ods_operator_user",
+  "sourceKeyField": "user_id",
+  "sourceField": "mobile",
+  "authorityScore": 100
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| entityType | String | 是 | 实体类型:PERSON/ENTERPRISE |
+| modelField | String | 是 | 模型字段名 |
+| sourceTable | String | 是 | 来源表名 |
+| sourceKeyField | String | 是 | 来源表主键字段名 |
+| sourceField | String | 是 | 来源字段名 |
+| authorityScore | Long | 是 | 优先级分:100/70/40 |
+
+**校验规则**:
+- authorityScore 只能为 100、70 或 40
+- 同一 model_field + entityType 下,相同分值只能配置一条
+- 同一 model_field 下,来源字段组合不能重复
+
+### 2.2 编辑
+
+| 项目 | 说明 |
+|------|------|
+| URL | `POST /sourceFieldAuthority/update` |
+
+**请求参数**:在新增基础上增加 `uuid` 字段
+```json
+{
+  "uuid": "xxx",
+  "entityType": "PERSON",
+  "modelField": "phone",
+  "sourceTable": "ods_operator_user",
+  "sourceKeyField": "user_id",
+  "sourceField": "mobile",
+  "authorityScore": 100
+}
+```
+
+### 2.3 删除
+
+| 项目 | 说明 |
+|------|------|
+| URL | `POST /sourceFieldAuthority/delete` |
+
+**请求参数**:
+```json
+{
+  "uuid": "xxx"
+}
+```
+
+### 2.4 详情
+
+| 项目 | 说明 |
+|------|------|
+| URL | `POST /sourceFieldAuthority/getInfo` |
+
+**请求参数**:
+```json
+{
+  "uuid": "xxx"
+}
+```
+
+**响应**:
+```json
+{
+  "code": 200,
+  "data": {
+    "uuid": "xxx",
+    "entityType": "PERSON",
+    "modelField": "phone",
+    "sourceTable": "ods_operator_user",
+    "sourceKeyField": "user_id",
+    "sourceField": "mobile",
+    "authorityScore": 100,
+    "createUser": "张三",
+    "createTime": 1719000000000,
+    "updateUser": null,
+    "updateTime": null
+  }
+}
+```
+
+### 2.5 分页查询
+
+| 项目 | 说明 |
+|------|------|
+| URL | `POST /sourceFieldAuthority/page` |
+
+**请求参数**:
+```json
+{
+  "pageNumber": 1,
+  "pageSize": 10,
+  "entityType": "PERSON",
+  "modelField": "phone"
+}
+```
+
+**响应**:
+```json
+{
+  "code": 200,
+  "data": {
+    "records": [],
+    "totalRow": 100,
+    "pageNumber": 1,
+    "pageSize": 10
+  }
+}
+```
+
+---
+
+## 三、两度模型 - 字段时效参数配置
+
+基础路径:`/fieldDecayConfig`,接口结构与来源优先级一致
+
+### 3.1 新增 `POST /fieldDecayConfig/save`
+
+```json
+{
+  "entityType": "PERSON",
+  "modelField": "phone",
+  "sourceTable": "ods_operator_user",
+  "sourceKeyField": "user_id",
+  "sourceField": "mobile",
+  "halfLifeDays": 90,
+  "decayFloor": 0.50
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| halfLifeDays | Long | 是 | 半衰期天数,必须大于0 |
+| decayFloor | Double | 是 | 最低时效分,0~1之间 |
+
+### 3.2 编辑 `POST /fieldDecayConfig/update`
+
+请求参数同新增,增加 `uuid` 字段
+
+### 3.3 删除 `POST /fieldDecayConfig/delete`
+
+```json
+{
+  "uuid": "xxx"
+}
+```
+
+### 3.4 详情 `POST /fieldDecayConfig/getInfo`
+
+```json
+{
+  "uuid": "xxx"
+}
+```
+
+**响应**:
+```json
+{
+  "code": 200,
+  "data": {
+    "uuid": "xxx",
+    "entityType": "PERSON",
+    "modelField": "phone",
+    "sourceTable": "ods_operator_user",
+    "sourceKeyField": "user_id",
+    "sourceField": "mobile",
+    "halfLifeDays": 90,
+    "decayFloor": 0.50,
+    "createUser": "张三",
+    "createTime": 1719000000000,
+    "updateUser": null,
+    "updateTime": null
+  }
+}
+```
+
+### 3.5 分页 `POST /fieldDecayConfig/page`
+
+```json
+{
+  "pageNumber": 1,
+  "pageSize": 10,
+  "entityType": "PERSON",
+  "modelField": "phone"
+}
+```
+
+---
+
+## 四、两度模型 - 多源一致性参数配置
+
+基础路径:`/consensusConfig`,接口结构与来源优先级一致
+
+### 4.1 新增 `POST /consensusConfig/save`
+
+```json
+{
+  "entityType": "PERSON",
+  "modelField": "phone",
+  "sourceTable": "ods_operator_user",
+  "sourceKeyField": "user_id",
+  "sourceField": "mobile",
+  "alpha": 0.85
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| alpha | Double | 是 | 孤证保底系数,0.80~0.95之间 |
+
+### 4.2 编辑 `POST /consensusConfig/update`
+
+请求参数同新增,增加 `uuid` 字段
+
+### 4.3 删除 `POST /consensusConfig/delete`
+
+```json
+{
+  "uuid": "xxx"
+}
+```
+
+### 4.4 详情 `POST /consensusConfig/getInfo`
+
+```json
+{
+  "uuid": "xxx"
+}
+```
+
+**响应**:
+```json
+{
+  "code": 200,
+  "data": {
+    "uuid": "xxx",
+    "entityType": "PERSON",
+    "modelField": "phone",
+    "sourceTable": "ods_operator_user",
+    "sourceKeyField": "user_id",
+    "sourceField": "mobile",
+    "alpha": 0.85,
+    "createUser": "张三",
+    "createTime": 1719000000000,
+    "updateUser": null,
+    "updateTime": null
+  }
+}
+```
+
+### 4.5 分页 `POST /consensusConfig/page`
+
+```json
+{
+  "pageNumber": 1,
+  "pageSize": 10,
+  "entityType": "PERSON",
+  "modelField": "phone"
+}
+```
+
+---
+
+## 五、校验规则汇总
+
+| 校验项 | 规则 | 提示信息 |
+|--------|------|---------|
+| 优先级分值 | authorityScore 只能为 100/70/40 | 优先级分只能为 100、70 或 40 |
+| 分值唯一性 | 同一 model_field + entityType 下同分值最多1条 | 该模型字段的 {score} 分已配置 |
+| 来源字段唯一性 | 同一 model_field 下来源字段组合唯一 | 该来源字段已配置,不能重复 |
+| 半衰期 | halfLifeDays > 0 | 半衰期天数必须大于 0 |
+| 最低时效分 | 0 < decayFloor < 1 | 最低时效分必须在 0-1 之间 |
+| 孤证保底系数 | 0.80 ≤ alpha ≤ 0.95 | 孤证保底系数建议在 0.80-0.95 之间 |

+ 6 - 0
src/api/wecom/index.ts

@@ -24,6 +24,7 @@ const ACCESS_TOKEN_EXPIRE_AT_KEY = 'CommunityFillingAccessTokenExpireAt'
 const VISITOR_PROFILE_KEY = 'CommunityFillingWecomVisitorProfile'
 const ONE_DAY_MS = 24 * 60 * 60 * 1000
 const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
+const INVALID_OAUTH_CODE_ERRCODE = 40029
 
 type VisitorProfileCacheRecord = {
   expireAt: number
@@ -158,6 +159,7 @@ export function getWecomUserIdentity(code: string) {
       },
     }),
     'getuserinfo',
+    { silentErrcodes: [INVALID_OAUTH_CODE_ERRCODE] },
   )
 }
 
@@ -315,6 +317,10 @@ export async function checkExternalUserInGroup(chatId: string, unionid: string):
 
 export async function syncExternalVisitorProfile(code: string) {
   const userInfo = await getWecomUserIdentity(code)
+  if (userInfo.errcode === INVALID_OAUTH_CODE_ERRCODE) {
+    throw new Error(userInfo.errmsg || 'invalid oauth code')
+  }
+
   const externalUserId = userInfo.external_userid || ''
 
   if (externalUserId) {

+ 80 - 8
src/pages/home/index.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { onLoad } from '@dcloudio/uni-app';
+import { onLoad, onShareAppMessage } from '@dcloudio/uni-app';
 import { ref } from 'vue';
 import globalConfig from '@/config/global';
 import * as ww from '@wecom/jssdk';
@@ -12,26 +12,93 @@ import type { WecomVisitorProfile } from '@/api/wecom/types';
 import { ensureBusinessAuthByVisitorProfile } from '@/utils/businessAuth/ensure'
 
 const AUTH_PAGE = '/pages/index/index'
+const HOME_PAGE = '/pages/home/index'
+const OAUTH_QUERY_KEYS = ['code', 'state']
+
+const removeOAuthParams = (params: URLSearchParams) => {
+  let changed = false
+  OAUTH_QUERY_KEYS.forEach((key) => {
+    if (params.has(key)) {
+      params.delete(key)
+      changed = true
+    }
+  })
+  return changed
+}
+
+const getHashSearchParams = () => {
+  if (typeof window === 'undefined') return null
+
+  const hash = window.location.hash.slice(1)
+  const queryStart = hash.indexOf('?')
+  if (queryStart === -1) return null
+
+  return new URLSearchParams(hash.slice(queryStart + 1))
+}
 
 const getCodeFromLocation = () => {
   if (typeof window === 'undefined') return ''
 
   const searchParams = new URLSearchParams(window.location.search)
-  return searchParams.get('code') || ''
+  return searchParams.get('code') || getHashSearchParams()?.get('code') || ''
 }
 
 const stripOAuthCodeFromUrl = () => {
   if (typeof window === 'undefined') return
   const url = new URL(window.location.href)
-  const hadOAuthParams = url.searchParams.has('code') || url.searchParams.has('state')
-  if (!hadOAuthParams) return
-  url.searchParams.delete('code')
-  url.searchParams.delete('state')
+  const changedSearch = removeOAuthParams(url.searchParams)
+
+  let changedHash = false
+  const rawHash = url.hash.slice(1)
+  const queryStart = rawHash.indexOf('?')
+  if (queryStart !== -1) {
+    const hashPath = rawHash.slice(0, queryStart)
+    const hashParams = new URLSearchParams(rawHash.slice(queryStart + 1))
+    changedHash = removeOAuthParams(hashParams)
+    if (changedHash) {
+      const nextHashSearch = hashParams.toString()
+      url.hash = `${hashPath}${nextHashSearch ? `?${nextHashSearch}` : ''}`
+    }
+  }
+
+  if (!changedSearch && !changedHash) return
   const nextSearch = url.searchParams.toString()
   const next = `${url.pathname}${nextSearch ? `?${nextSearch}` : ''}${url.hash}`
   window.history.replaceState({}, '', next)
 }
 
+const getCleanHomeUrl = () =>
+  typeof window === 'undefined'
+    ? HOME_PAGE
+    : `${window.location.origin}/#${HOME_PAGE}`
+
+const setupCleanShareUrl = () => {
+  if (typeof window === 'undefined') return
+
+  const shareData = {
+    title: '首页',
+    desc: '江北两度智治',
+    link: getCleanHomeUrl(),
+    imgUrl: '',
+  }
+
+  try {
+    ww.register({
+      corpId: globalConfig.corpid,
+      agentId: globalConfig.agentId,
+      jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData'],
+    })
+    void ww.updateAppMessageShareData(shareData).catch((err) => {
+      console.warn('分享给朋友配置失败:', err)
+    })
+    void ww.updateTimelineShareData(shareData).catch((err) => {
+      console.warn('分享到朋友圈配置失败:', err)
+    })
+  } catch (err) {
+    console.warn('分享链接配置失败:', err)
+  }
+}
+
 const externalUserid = ref('');
 const unionidValue = ref('');
 const ownerId = ref('');
@@ -105,6 +172,8 @@ const goReauthorize = () => {
 
 onLoad(async (option) => {
   const code = (option && option.code) || getCodeFromLocation()
+  stripOAuthCodeFromUrl()
+  setupCleanShareUrl()
 
   if (code) {
     try {
@@ -120,8 +189,6 @@ onLoad(async (option) => {
       } else {
         goReauthorize()
       }
-    } finally {
-      stripOAuthCodeFromUrl()
     }
     return
   }
@@ -137,6 +204,11 @@ onLoad(async (option) => {
   goReauthorize()
 })
 
+onShareAppMessage(() => ({
+  title: '首页',
+  path: HOME_PAGE,
+}))
+
 const getQuestionPage = () => {
   const ownerQuery = ownerId.value ? `?owner=${encodeURIComponent(ownerId.value)}` : ''
   uni.navigateTo({