feat(钉钉): 增加统一通知中心免登灰度

This commit is contained in:
WuYongJie 2026-07-15 21:06:36 +08:00
parent a1ac722317
commit 0a0145210d
26 changed files with 1040 additions and 42 deletions

View File

@ -1,6 +1,8 @@
MYSQL_ROOT_PASSWORD=change-me-local-password
MYSQL_DATABASE=equipment_management
SESSION_SECRET=change-me-session-secret
SESSION_COOKIE_SECURE=false
CORS_ALLOWED_ORIGINS=http://localhost:5173
AUTH_MODE=local
PORT=3000
VITE_API_BASE_URL=/api
@ -32,3 +34,6 @@ YYD_NOTIFICATION_CENTER_PROJECT_CODE=fixed_asset
YYD_NOTIFICATION_CENTER_PROJECT_SECRET=
YYD_NOTIFICATION_CENTER_TEST_USER_IDS=
YYD_NOTIFICATION_CENTER_FULL_ROLLOUT=false
YYD_NOTIFICATION_CENTER_LOGIN_ENABLED=false
YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS=
YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT=false

View File

@ -1,5 +1,7 @@
FROM node:20-alpine
ENV NODE_ENV=production
# 安装OpenSSL
RUN apk add --no-cache openssl openssl-dev

View File

@ -0,0 +1,167 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { loginWithYouyidianTicket } from '../dingtalkController'
const originalFetch = global.fetch
const originalFindFirst = prisma.user.findFirst
const originalFindUnique = prisma.user.findUnique
const envKeys = [
'YYD_NOTIFICATION_CENTER_URL',
'YYD_NOTIFICATION_CENTER_PROJECT_CODE',
'YYD_NOTIFICATION_CENTER_PROJECT_SECRET',
'YYD_NOTIFICATION_CENTER_LOGIN_ENABLED',
'YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS',
'YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT'
] as const
const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]))
const configureLogin = (testUserId = 'ding-user-1') => {
process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com'
process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset'
process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234'
process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'true'
process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = testUserId
process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'false'
}
const createRequest = (ticket = 'A'.repeat(43)) => {
const session: any = {
regenerate(callback: (error?: Error) => void) {
callback()
},
save(callback: (error?: Error) => void) {
callback()
}
}
return { body: { ticket }, session }
}
const createResponse = () => {
const state = {
statusCode: 200,
headers: {} as Record<string, string>,
type: '',
body: '',
redirect: null as null | { status: number; location: string }
}
const res = {
set(headers: Record<string, string>) {
Object.assign(state.headers, headers)
return this
},
status(code: number) {
state.statusCode = code
return this
},
type(value: string) {
state.type = value
return this
},
send(value: string) {
state.body = value
return this
},
redirect(status: number, location: string) {
state.statusCode = status
state.redirect = { status, location }
return this
}
}
return { res, state }
}
test.after(() => {
global.fetch = originalFetch
;(prisma.user.findFirst as any) = originalFindFirst
;(prisma.user.findUnique as any) = originalFindUnique
for (const key of envKeys) {
if (originalEnv[key] === undefined) delete process.env[key]
else process.env[key] = originalEnv[key]
}
})
test('一次性票据兑换成功后建立绑定userid的会话并303进入内部完成页', async () => {
configureLogin()
let capturedUrl = ''
global.fetch = (async (url: string | URL | Request) => {
capturedUrl = String(url)
return new Response(JSON.stringify({
dingtalkUserId: 'ding-user-1',
redirectPath: '/workflows/disposals/53'
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
}) as typeof fetch
;(prisma.user.findFirst as any) = async () => ({ id: 3 })
;(prisma.user.findUnique as any) = async () => ({
id: 3,
username: 'magic-red',
name: '魔礼红',
email: null,
phone: null,
role: 'admin',
status: 'active',
person_id: 108,
person: {
id: 108,
real_name: '吴永杰',
nickname: '魔礼红',
status: 'active',
source_employee_status: '3'
}
})
const req = createRequest()
const { res, state } = createResponse()
await loginWithYouyidianTicket(req as any, res as any)
assert.deepEqual(req.session.user, {
id: 3,
auth_source: 'dingtalk',
dingtalk_userid: 'ding-user-1'
})
assert.deepEqual(state.redirect, {
status: 303,
location: '/dingtalk-entry?broker=complete&redirect=%2Fworkflows%2Fdisposals%2F53'
})
assert.equal(state.headers['Cache-Control'], 'no-store')
assert.equal(capturedUrl, 'https://notify.example.com/v1/auth/tickets/redeem')
assert.equal(capturedUrl.includes('A'.repeat(43)), false)
})
test('票据属于灰度范围外用户时不建立会话也不查询系统账号', async () => {
configureLogin('ding-test-only')
global.fetch = (async () => new Response(JSON.stringify({
dingtalkUserId: 'ding-user-1',
redirectPath: '/dashboard'
}), { status: 200, headers: { 'Content-Type': 'application/json' } })) as typeof fetch
let userLookups = 0
;(prisma.user.findFirst as any) = async () => {
userLookups += 1
return { id: 3 }
}
const req = createRequest('B'.repeat(43))
const { res, state } = createResponse()
await loginWithYouyidianTicket(req as any, res as any)
assert.equal(userLookups, 0)
assert.equal(req.session.user, undefined)
assert.equal(state.statusCode, 403)
assert.match(state.body, /不在本次免登灰度范围/)
})
test('通知中心返回外部跳转地址时拒绝建立会话', async () => {
configureLogin()
global.fetch = (async () => new Response(JSON.stringify({
dingtalkUserId: 'ding-user-1',
redirectPath: 'https://evil.example/path'
}), { status: 200, headers: { 'Content-Type': 'application/json' } })) as typeof fetch
const req = createRequest('C'.repeat(43))
const { res, state } = createResponse()
await loginWithYouyidianTicket(req as any, res as any)
assert.equal(req.session.user, undefined)
assert.equal(state.statusCode, 401)
assert.match(state.body, /登录凭证无效或已过期/)
})

View File

@ -1,6 +1,9 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { normalizeInternalRedirect } from '../../utils/dingtalkRedirect'
import {
buildDingTalkBrokerCompletionPath,
normalizeInternalRedirect
} from '../../utils/dingtalkRedirect'
test('钉钉免登只接受白名单内的系统路径', () => {
assert.equal(
@ -16,3 +19,14 @@ test('钉钉免登拒绝外链、协议相对地址和非白名单路径', () =>
assert.throws(() => normalizeInternalRedirect('/admin/secrets'), /允许范围/)
assert.throws(() => normalizeInternalRedirect('/workflows/disposals-evil'), /允许范围/)
})
test('票据兑换后只通过无敏感信息的内部完成页恢复前端会话', () => {
assert.equal(
buildDingTalkBrokerCompletionPath('/workflows/disposals/123?source=ding'),
'/dingtalk-entry?broker=complete&redirect=%2Fworkflows%2Fdisposals%2F123%3Fsource%3Dding'
)
assert.throws(
() => buildDingTalkBrokerCompletionPath('https://evil.example/path'),
/允许范围/
)
})

View File

@ -7,11 +7,81 @@ import {
getDingTalkConfigStatus,
getDingTalkPublicConfig
} from '../services/dingtalkClient'
import { prepareNotificationCenterManualRetryPayload } from '../services/youyidianNotificationClient'
import { normalizeInternalRedirect } from '../utils/dingtalkRedirect'
import {
NotificationCenterError,
getNotificationCenterLoginConfigStatus,
isNotificationCenterLoginAllowedForUser,
prepareNotificationCenterManualRetryPayload,
redeemYouyidianLoginTicket
} from '../services/youyidianNotificationClient'
import { findEligibleDingTalkLoginUser } from '../services/dingtalkBrokerLoginService'
import {
buildDingTalkBrokerCompletionPath,
normalizeInternalRedirect
} from '../utils/dingtalkRedirect'
export const getPublicDingTalkConfig = (_req: Request, res: Response) => {
successResponse(res, getDingTalkPublicConfig(), '获取成功')
const establishUserSession = async (
req: Request,
userId: number,
authSource?: 'dingtalk',
dingtalkUserId?: string
) => {
await new Promise<void>((resolve, reject) => {
;((req as any).session as any).regenerate((error?: Error) => {
if (error) return reject(error)
resolve()
})
})
;((req as any).session as any).user = authSource
? { id: userId, auth_source: authSource, dingtalk_userid: dingtalkUserId }
: { id: userId }
await new Promise<void>((resolve, reject) => {
;((req as any).session as any).save((error?: Error) => {
if (error) return reject(error)
resolve()
})
})
}
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[character] || character))
const sendBrokerLoginError = (res: Response, status: number, message: string) => {
res.set({
'Cache-Control': 'no-store',
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'",
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff'
})
return res.status(status).type('html').send(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>固定资产免登失败</title>
<style>
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0f14;color:#f3f4f6;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;padding:20px;box-sizing:border-box}
main{width:min(420px,100%);padding:24px;border:1px solid #253041;border-radius:14px;background:#111821;box-sizing:border-box}
h1{font-size:20px;margin:0 0 12px}p{font-size:14px;line-height:1.7;color:#aab4c3;margin:0 0 20px}
a{display:block;text-align:center;padding:10px 14px;border:1px solid #334155;border-radius:9px;color:#e5e7eb;text-decoration:none}
</style>
</head>
<body><main><h1>暂时无法进入固定资产系统</h1><p>${escapeHtml(message)}</p><a href="/login">返回系统登录页</a></main></body>
</html>`)
}
export const getPublicDingTalkConfig = (req: Request, res: Response) => {
try {
const safeRedirect = normalizeInternalRedirect(req.query.redirectPath ?? req.query.redirect)
successResponse(res, getDingTalkPublicConfig(safeRedirect), '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取钉钉免登配置失败', 422)
}
}
export const loginWithDingTalkCode = async (req: Request, res: Response) => {
@ -23,31 +93,13 @@ export const loginWithDingTalkCode = async (req: Request, res: Response) => {
const safeRedirect = normalizeInternalRedirect(redirectPath ?? redirect_path)
const dingTalkUserId = await exchangeDingTalkLoginCode(code.trim())
const user = await prisma.user.findFirst({
where: {
status: 'active',
person_id: { not: null },
person: {
is: {
source_userid: dingTalkUserId,
status: 'active'
}
}
},
select: { id: true }
})
const user = await findEligibleDingTalkLoginUser(dingTalkUserId)
if (!user) {
return errorResponse(res, '未找到启用且绑定在职员工档案的系统账号', 403)
}
const authUser = await resolveAuthUserById(user.id)
await new Promise<void>((resolve, reject) => {
;(req.session as any).regenerate((error?: Error) => {
if (error) return reject(error)
resolve()
})
})
;(req.session as any).user = { id: user.id }
await establishUserSession(req, user.id, 'dingtalk', dingTalkUserId)
successResponse(res, {
user: authUser,
@ -60,6 +112,53 @@ export const loginWithDingTalkCode = async (req: Request, res: Response) => {
}
}
export const loginWithYouyidianTicket = async (req: Request, res: Response) => {
res.set({
'Cache-Control': 'no-store',
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff'
})
try {
const ticket = typeof req.body?.ticket === 'string' ? req.body.ticket.trim() : ''
if (!/^[A-Za-z0-9_-]{32,256}$/.test(ticket)) {
return sendBrokerLoginError(res, 422, '登录凭证格式无效,请返回钉钉重新打开通知。')
}
const redeemed = await redeemYouyidianLoginTicket(ticket)
const safeRedirect = normalizeInternalRedirect(redeemed.redirectPath)
if (!isNotificationCenterLoginAllowedForUser(redeemed.dingtalkUserId)) {
return sendBrokerLoginError(res, 403, '当前钉钉账号不在本次免登灰度范围内,请使用原登录方式。')
}
const user = await findEligibleDingTalkLoginUser(redeemed.dingtalkUserId)
if (!user) {
return sendBrokerLoginError(res, 403, '当前钉钉账号未绑定启用且在职的固定资产系统账号,请联系管理员。')
}
await resolveAuthUserById(user.id)
await establishUserSession(req, user.id, 'dingtalk', redeemed.dingtalkUserId)
return res.redirect(303, buildDingTalkBrokerCompletionPath(safeRedirect))
} catch (error: any) {
if (error instanceof NotificationCenterError) {
if (error.code === 'CONFIG_INCOMPLETE') {
return sendBrokerLoginError(res, 503, '统一通知登录服务尚未配置完整,请联系管理员。')
}
if (error.code === 'TIMEOUT') {
return sendBrokerLoginError(res, 504, '统一通知登录服务响应超时,请返回钉钉后重试。')
}
if (error.code === 'NETWORK') {
return sendBrokerLoginError(res, 502, '暂时无法连接统一通知登录服务,请稍后重试。')
}
return sendBrokerLoginError(res, 401, '登录凭证无效或已过期,请返回钉钉重新打开通知。')
}
const status = Number.isInteger(error?.status) ? error.status : 500
return sendBrokerLoginError(
res,
status,
status === 403 ? '当前系统账号不可用,请联系管理员。' : '建立登录会话失败,请稍后重试。'
)
}
}
export const getDingTalkDeliveryStatus = async (_req: Request, res: Response) => {
try {
const [pending, failed, sent, read, unread, errorGroups] = await Promise.all([
@ -77,12 +176,18 @@ export const getDingTalkDeliveryStatus = async (_req: Request, res: Response) =>
})
])
const config = getDingTalkConfigStatus()
const loginConfig = getNotificationCenterLoginConfigStatus()
successResponse(res, {
configured: config.configured,
channel: config.channel,
send_enabled: config.send_enabled,
enabled: config.send_enabled,
missing: config.missing,
login_configured: loginConfig.configured,
login_enabled: loginConfig.enabled,
login_rollout_mode: loginConfig.rollout_mode,
login_test_user_count: loginConfig.test_user_count,
login_missing: loginConfig.missing,
pending,
failed,
sent,

View File

@ -38,15 +38,25 @@ import { PERMISSIONS } from './utils/permissions'
import { startHrSyncScheduler } from './services/hrSyncService'
import { ensurePublicAssetPersonExists } from './services/systemReservedPersonService'
import { startDingTalkDeliveryWorker } from './services/dingtalkDeliveryService'
import { isCorsOriginAllowed } from './utils/corsPolicy'
import { unsafeOriginGuard } from './middleware/originGuard'
dotenv.config()
const app = express()
const PORT = process.env.PORT || 3000
const SESSION_SECRET = process.env.SESSION_SECRET || 'equipment-management-secret'
const SESSION_COOKIE_SECURE = process.env.SESSION_COOKIE_SECURE === undefined
? process.env.NODE_ENV === 'production'
: process.env.SESSION_COOKIE_SECURE === 'true'
// Middleware
app.use(cors({ origin: true, credentials: true }))
app.set('trust proxy', 1)
app.use(cors({
origin: (origin, callback) => callback(null, isCorsOriginAllowed(origin) ? (origin || true) : false),
credentials: true
}))
app.use(unsafeOriginGuard)
app.use(express.json({ limit: '50mb' }))
app.use(express.urlencoded({ extended: true, limit: '50mb' }))
app.use(
@ -56,7 +66,9 @@ app.use(
saveUninitialized: false,
store: memoryStore,
cookie: {
secure: false,
secure: SESSION_COOKIE_SECURE,
httpOnly: true,
sameSite: 'lax',
maxAge: 1000 * 60 * 60 // 1 hour
}
})

View File

@ -148,3 +148,48 @@ test('local 模式即使带真实 Bearer 也只使用本地 session', async () =
;(prisma.user.findUnique as any) = originalFindUnique
}
})
test('钉钉会话在员工改绑后立即失效且不继续读取系统用户', async () => {
const originalMode = process.env.AUTH_MODE
const originalFindFirst = prisma.user.findFirst
const originalFindUnique = prisma.user.findUnique
let findUniqueCalls = 0
process.env.AUTH_MODE = 'local'
;(prisma.user.findFirst as any) = async (args: any) => {
assert.equal(args.where.id, 41)
assert.equal(args.where.person.is.source_userid, 'ding-user-a')
return null
}
;(prisma.user.findUnique as any) = async () => {
findUniqueCalls += 1
return makeUser(41, 'rebound-user')
}
try {
const middleware = protect()
const req = {
headers: {},
session: {
user: {
id: 41,
auth_source: 'dingtalk',
dingtalk_userid: 'ding-user-a'
}
}
}
const { res, next, state } = createResponse()
await middleware(req as any, res as any, next as any)
assert.equal(state.nextCount, 0)
assert.equal(state.statusCode, 401)
assert.equal(state.body.message, '钉钉绑定已失效,请重新登录或联系管理员')
assert.equal(req.session.user, undefined)
assert.equal(findUniqueCalls, 0)
} finally {
if (originalMode === undefined) delete process.env.AUTH_MODE
else process.env.AUTH_MODE = originalMode
;(prisma.user.findFirst as any) = originalFindFirst
;(prisma.user.findUnique as any) = originalFindUnique
}
})

View File

@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { unsafeOriginGuard } from '../originGuard'
const originalEnv = {
nodeEnv: process.env.NODE_ENV,
publicBaseUrl: process.env.DINGTALK_PUBLIC_BASE_URL,
centerUrl: process.env.YYD_NOTIFICATION_CENTER_URL,
corsOrigins: process.env.CORS_ALLOWED_ORIGINS
}
test.before(() => {
process.env.NODE_ENV = 'production'
process.env.DINGTALK_PUBLIC_BASE_URL = 'https://asset.example.com'
process.env.YYD_NOTIFICATION_CENTER_URL = 'https://sc.example.com/notification-center'
process.env.CORS_ALLOWED_ORIGINS = ''
})
test.after(() => {
for (const [key, value] of Object.entries({
NODE_ENV: originalEnv.nodeEnv,
DINGTALK_PUBLIC_BASE_URL: originalEnv.publicBaseUrl,
YYD_NOTIFICATION_CENTER_URL: originalEnv.centerUrl,
CORS_ALLOWED_ORIGINS: originalEnv.corsOrigins
})) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
})
const runGuard = (input: { method: string; origin?: string; path: string }) => {
const state = { status: 200, body: null as any, nextCalls: 0 }
const req = {
method: input.method,
protocol: 'https',
path: input.path,
get(name: string) {
if (name === 'host') return 'asset.example.com'
if (name === 'origin') return input.origin
return undefined
}
}
const res = {
status(code: number) {
state.status = code
return this
},
json(body: any) {
state.body = body
return this
}
}
unsafeOriginGuard(req as any, res as any, () => {
state.nextCalls += 1
})
return state
}
test('恶意同站来源的资产写操作在进入业务路由前返回403', () => {
const result = runGuard({
method: 'POST',
origin: 'https://sc.example.com',
path: '/api/equipment'
})
assert.equal(result.status, 403)
assert.equal(result.nextCalls, 0)
assert.deepEqual(result.body, { code: 403, message: '请求来源不允许', data: null })
})
test('通知中心来源只可进入一次性票据回调', () => {
assert.equal(runGuard({
method: 'POST',
origin: 'https://sc.example.com',
path: '/api/dingtalk/broker-login'
}).nextCalls, 1)
assert.equal(runGuard({
method: 'DELETE',
origin: 'https://sc.example.com',
path: '/api/equipment/1'
}).status, 403)
})

View File

@ -6,6 +6,7 @@ import {
resolveOrProvisionAuthUser
} from '../utils/authIdentity'
import { PERMISSIONS, type Permission } from '../utils/permissions'
import { findEligibleDingTalkSessionUser } from '../services/dingtalkBrokerLoginService'
const isLocalAuth = () => process.env.AUTH_MODE === 'local'
@ -22,14 +23,31 @@ export function protect(): RequestHandler {
return async function authWrapper(req: Request, res: Response, next: NextFunction) {
const authenticateSession = async () => {
const sessionUserId = Number((req as any).session?.user?.id)
const sessionUser = (req as any).session?.user
const sessionUserId = Number(sessionUser?.id)
const authSource = sessionUser?.auth_source === 'dingtalk' ? 'dingtalk' : undefined
const sessionDingTalkUserId = authSource === 'dingtalk'
? String(sessionUser?.dingtalk_userid || '').trim()
: ''
if (!Number.isInteger(sessionUserId) || sessionUserId <= 0) {
return false
}
try {
if (authSource === 'dingtalk') {
const eligibleUser = await findEligibleDingTalkSessionUser(sessionUserId, sessionDingTalkUserId)
if (!eligibleUser) {
throw new AuthIdentityError('钉钉绑定已失效,请重新登录或联系管理员', 401)
}
}
const currentUser = await resolveAuthUserById(sessionUserId)
;(req as any).session.user = { id: currentUser.id }
;(req as any).session.user = authSource
? {
id: currentUser.id,
auth_source: authSource,
dingtalk_userid: sessionDingTalkUserId
}
: { id: currentUser.id }
res.locals.user = currentUser
next()
return true

View File

@ -0,0 +1,15 @@
import type { RequestHandler } from 'express'
import { isUnsafeRequestOriginAllowed } from '../utils/corsPolicy'
export const unsafeOriginGuard: RequestHandler = (req, res, next) => {
const requestOrigin = `${req.protocol}://${req.get('host') || ''}`
if (!isUnsafeRequestOriginAllowed({
method: req.method,
origin: req.get('origin'),
requestOrigin,
path: req.path
})) {
return res.status(403).json({ code: 403, message: '请求来源不允许', data: null })
}
return next()
}

View File

@ -4,6 +4,7 @@ import {
getDingTalkDeliveryStatus,
getPublicDingTalkConfig,
loginWithDingTalkCode,
loginWithYouyidianTicket,
retryDingTalkDelivery
} from '../controllers/dingtalkController'
import { protect, requirePermission } from '../middleware/auth'
@ -13,6 +14,7 @@ const router = Router()
router.get('/public-config', getPublicDingTalkConfig)
router.post('/h5-login', loginWithDingTalkCode)
router.post('/broker-login', loginWithYouyidianTicket)
router.use('/deliveries', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS))
router.get('/deliveries/status', getDingTalkDeliveryStatus)

View File

@ -0,0 +1,74 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
findEligibleDingTalkLoginUser,
findEligibleDingTalkSessionUser
} from '../dingtalkBrokerLoginService'
test('钉钉免登只匹配启用账号、已绑定员工、在职档案和精确userid', async () => {
let captured: any
const client = {
user: {
findFirst: async (args: any) => {
captured = args
return { id: 3 }
}
}
}
const user = await findEligibleDingTalkLoginUser(' ding-user-1 ', client as any)
assert.deepEqual(user, { id: 3 })
assert.deepEqual(captured, {
where: {
status: 'active',
person_id: { not: null },
person: {
is: {
source_userid: 'ding-user-1',
status: 'active'
}
}
},
select: { id: true }
})
})
test('钉钉会话每次请求重新核验绑定和在职状态', async () => {
let captured: any
const client = {
user: {
findFirst: async (args: any) => {
captured = args
return { id: 3 }
}
}
}
const user = await findEligibleDingTalkSessionUser(3, 'ding-user-1', client as any)
assert.deepEqual(user, { id: 3 })
assert.deepEqual(captured.where, {
id: 3,
status: 'active',
person_id: { not: null },
person: {
is: {
source_userid: 'ding-user-1',
status: 'active'
}
}
})
})
test('空userid和非法用户编号不会查询数据库', async () => {
let calls = 0
const client = {
user: {
findFirst: async () => {
calls += 1
return { id: 3 }
}
}
}
assert.equal(await findEligibleDingTalkLoginUser(' ', client as any), null)
assert.equal(await findEligibleDingTalkSessionUser(0, 'ding-user-1', client as any), null)
assert.equal(await findEligibleDingTalkSessionUser(3, '', client as any), null)
assert.equal(calls, 0)
})

View File

@ -9,7 +9,8 @@ test('钉钉 H5 公开配置返回 clientId 但不泄露 AppSecret', () => {
'DINGTALK_APP_SECRET',
'DINGTALK_AGENT_ID',
'DINGTALK_CORP_ID',
'DINGTALK_PUBLIC_BASE_URL'
'DINGTALK_PUBLIC_BASE_URL',
'YYD_NOTIFICATION_CENTER_LOGIN_ENABLED'
] as const
const original = Object.fromEntries(keys.map((key) => [key, process.env[key]]))
@ -18,11 +19,13 @@ test('钉钉 H5 公开配置返回 clientId 但不泄露 AppSecret', () => {
process.env.DINGTALK_AGENT_ID = '10001'
process.env.DINGTALK_CORP_ID = 'ding-corp'
process.env.DINGTALK_PUBLIC_BASE_URL = 'https://assets.example.com'
process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'false'
try {
const config = getDingTalkPublicConfig()
assert.deepEqual(config, {
configured: true,
mode: 'work_notice',
corpId: 'ding-corp',
clientId: 'ding-app-key'
})
@ -77,3 +80,56 @@ test('通知中心配置状态只返回灰度数量且不泄露密钥或员工us
}
}
})
test('免登灰度不接管旧入口,只有全量开关才统一跳转通知中心', () => {
const keys = [
'DINGTALK_APP_KEY',
'DINGTALK_APP_SECRET',
'DINGTALK_AGENT_ID',
'DINGTALK_CORP_ID',
'DINGTALK_PUBLIC_BASE_URL',
'YYD_NOTIFICATION_CENTER_URL',
'YYD_NOTIFICATION_CENTER_PROJECT_CODE',
'YYD_NOTIFICATION_CENTER_PROJECT_SECRET',
'YYD_NOTIFICATION_CENTER_LOGIN_ENABLED',
'YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS',
'YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT'
] as const
const original = Object.fromEntries(keys.map((key) => [key, process.env[key]]))
delete process.env.DINGTALK_APP_KEY
delete process.env.DINGTALK_APP_SECRET
delete process.env.DINGTALK_AGENT_ID
delete process.env.DINGTALK_CORP_ID
process.env.DINGTALK_PUBLIC_BASE_URL = 'https://asset.example.com'
process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com'
process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset'
process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234'
process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'true'
process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = 'ding-test-user'
process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'false'
try {
assert.deepEqual(getDingTalkPublicConfig('/dashboard'), {
configured: false,
mode: 'work_notice',
corpId: '',
clientId: ''
})
process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'true'
const fullConfig = getDingTalkPublicConfig('/dashboard')
assert.equal(fullConfig.configured, true)
assert.equal(fullConfig.mode, 'notification_center')
assert.equal(fullConfig.rolloutMode, 'full')
assert.equal(
fullConfig.loginEntryUrl,
'https://notify.example.com/v1/auth/h5-entry?projectCode=fixed_asset&redirectPath=%2Fdashboard'
)
} finally {
for (const key of keys) {
if (original[key] === undefined) delete process.env[key]
else process.env[key] = original[key]
}
}
})

View File

@ -1,10 +1,14 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildYouyidianLoginEntryUrl,
createNotificationCenterSignature,
getNotificationCenterConfigStatus,
getNotificationCenterLoginConfigStatus,
isNotificationCenterLoginAllowedForUser,
NotificationCenterError,
prepareNotificationCenterManualRetryPayload,
redeemYouyidianLoginTicket,
submitYouyidianNotification
} from '../youyidianNotificationClient'
@ -13,7 +17,10 @@ const originalEnv = {
url: process.env.YYD_NOTIFICATION_CENTER_URL,
code: process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE,
secret: process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET,
publicBaseUrl: process.env.DINGTALK_PUBLIC_BASE_URL
publicBaseUrl: process.env.DINGTALK_PUBLIC_BASE_URL,
loginEnabled: process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED,
loginTestUsers: process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS,
loginFullRollout: process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT
}
test.after(() => {
@ -26,6 +33,12 @@ test.after(() => {
else process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = originalEnv.secret
if (originalEnv.publicBaseUrl === undefined) delete process.env.DINGTALK_PUBLIC_BASE_URL
else process.env.DINGTALK_PUBLIC_BASE_URL = originalEnv.publicBaseUrl
if (originalEnv.loginEnabled === undefined) delete process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED
else process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = originalEnv.loginEnabled
if (originalEnv.loginTestUsers === undefined) delete process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS
else process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = originalEnv.loginTestUsers
if (originalEnv.loginFullRollout === undefined) delete process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT
else process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = originalEnv.loginFullRollout
})
test('HMAC签名固定覆盖POST正文与GET空正文', () => {
@ -83,7 +96,76 @@ test('固定资产通过独立项目签名提交到由壹点通知中心', async
assert.equal(headers['x-yyd-project-code'], 'fixed_asset')
assert.match(headers['x-yyd-signature'], /^[a-f0-9]{64}$/)
const body = JSON.parse(String(captured.init?.body))
assert.equal(body.actionUrl, 'https://asset.example.com/dingtalk-entry?redirect=%2Fworkflows%2Fdisposals%2F53')
assert.equal(body.actionUrl, 'https://asset.example.com/workflows/disposals/53')
})
test('统一免登默认关闭且灰度时只放行指定钉钉用户', () => {
process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com'
process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset'
process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234'
process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'false'
process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = ''
process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'false'
assert.equal(getNotificationCenterLoginConfigStatus().configured, false)
process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'true'
process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = 'ding-test-user'
assert.equal(getNotificationCenterLoginConfigStatus().rollout_mode, 'test')
assert.equal(isNotificationCenterLoginAllowedForUser('ding-test-user'), true)
assert.equal(isNotificationCenterLoginAllowedForUser('other-user'), false)
})
test('统一免登入口只携带项目代码和白名单内路径', () => {
process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com'
process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset'
process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234'
process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'true'
process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = 'ding-test-user'
process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'false'
assert.equal(
buildYouyidianLoginEntryUrl('/workflows/disposals/53?source=ding'),
'https://notify.example.com/v1/auth/h5-entry?projectCode=fixed_asset&redirectPath=%2Fworkflows%2Fdisposals%2F53%3Fsource%3Dding'
)
assert.throws(
() => buildYouyidianLoginEntryUrl('https://evil.example/path'),
/允许范围/
)
})
test('固定资产以项目HMAC兑换一次性票据并再次校验跳转地址', async () => {
process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com'
process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE = 'fixed_asset'
process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET = '123456789012345678901234'
process.env.YYD_NOTIFICATION_CENTER_LOGIN_ENABLED = 'true'
process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS = 'ding-user-1'
process.env.YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT = 'false'
let captured: { url?: string; init?: RequestInit } = {}
global.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
captured = { url: String(url), init }
return new Response(JSON.stringify({
dingtalkUserId: 'ding-user-1',
redirectPath: '/workflows/disposals/53'
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
}) as typeof fetch
const result = await redeemYouyidianLoginTicket('A'.repeat(43))
assert.deepEqual(result, {
dingtalkUserId: 'ding-user-1',
redirectPath: '/workflows/disposals/53'
})
assert.equal(captured.url, 'https://notify.example.com/v1/auth/tickets/redeem')
assert.deepEqual(JSON.parse(String(captured.init?.body)), { ticket: 'A'.repeat(43) })
const headers = captured.init?.headers as Record<string, string>
assert.match(headers['x-yyd-signature'], /^[a-f0-9]{64}$/)
global.fetch = (async () => new Response(JSON.stringify({
dingtalkUserId: 'ding-user-1',
redirectPath: 'https://evil.example/path'
}), { status: 200, headers: { 'Content-Type': 'application/json' } })) as typeof fetch
await assert.rejects(
redeemYouyidianLoginTicket('B'.repeat(43)),
(error: unknown) => error instanceof NotificationCenterError && error.code === 'INVALID_RESPONSE'
)
})
test('固定资产客户端拒绝误用其它项目代码', () => {

View File

@ -0,0 +1,49 @@
import prisma from '../utils/prisma'
type UserLookupClient = Pick<typeof prisma, 'user'>
export const findEligibleDingTalkLoginUser = async (
dingtalkUserId: string,
client: UserLookupClient = prisma
) => {
const normalizedUserId = String(dingtalkUserId || '').trim()
if (!normalizedUserId) return null
return client.user.findFirst({
where: {
status: 'active',
person_id: { not: null },
person: {
is: {
source_userid: normalizedUserId,
status: 'active'
}
}
},
select: { id: true }
})
}
export const findEligibleDingTalkSessionUser = async (
userId: number,
dingtalkUserId: string,
client: UserLookupClient = prisma
) => {
const normalizedDingTalkUserId = String(dingtalkUserId || '').trim()
if (!Number.isInteger(userId) || userId <= 0 || !normalizedDingTalkUserId) return null
return client.user.findFirst({
where: {
id: userId,
status: 'active',
person_id: { not: null },
person: {
is: {
source_userid: normalizedDingTalkUserId,
status: 'active'
}
}
},
select: { id: true }
})
}

View File

@ -1,4 +1,8 @@
import { getNotificationCenterConfigStatus } from './youyidianNotificationClient'
import {
buildYouyidianLoginEntryUrl,
getNotificationCenterConfigStatus,
getNotificationCenterLoginConfigStatus
} from './youyidianNotificationClient'
type DingTalkResponse = {
errcode?: number
@ -86,12 +90,25 @@ export const getDingTalkConfigStatus = () => {
}
}
export const getDingTalkPublicConfig = () => {
export const getDingTalkPublicConfig = (redirectPath: string = '/') => {
const config = requiredConfig()
const notificationCenterLoginConfig = getNotificationCenterLoginConfigStatus()
if (
notificationCenterLoginConfig.configured &&
notificationCenterLoginConfig.rollout_mode === 'full'
) {
return {
configured: true,
mode: 'notification_center' as const,
loginEntryUrl: buildYouyidianLoginEntryUrl(redirectPath),
rolloutMode: notificationCenterLoginConfig.rollout_mode
}
}
return {
configured: Boolean(
config.appKey && config.appSecret && config.agentId && config.corpId && config.publicBaseUrl
),
mode: 'work_notice' as const,
corpId: config.corpId,
// requestAuthCode JSAPI 要求传入企业内部应用 AppKey;AppKey 是客户端标识,不包含 AppSecret。
clientId: config.appKey

View File

@ -1,4 +1,5 @@
import crypto from 'crypto'
import { normalizeInternalRedirect } from '../utils/dingtalkRedirect'
export type NotificationCenterStatus =
'pending' | 'processing' | 'sending' | 'retrying' | 'sent' | 'unread' | 'read' | 'failed'
@ -33,6 +34,15 @@ const config = () => ({
secret: String(process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET || '').trim()
})
const envFlag = (name: string) => String(process.env[name] || '').trim().toLowerCase() === 'true'
const notificationCenterLoginTestUserIds = () => Array.from(new Set(
String(process.env.YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
))
export const getNotificationCenterConfigStatus = () => {
const value = config()
const missing: string[] = []
@ -58,6 +68,31 @@ export const getNotificationCenterConfigStatus = () => {
return { configured: missing.length === 0, missing }
}
export const getNotificationCenterLoginConfigStatus = () => {
const enabled = envFlag('YYD_NOTIFICATION_CENTER_LOGIN_ENABLED')
const fullRollout = envFlag('YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT')
const testUserIds = notificationCenterLoginTestUserIds()
const missing = [...getNotificationCenterConfigStatus().missing]
if (!enabled) missing.push('YYD_NOTIFICATION_CENTER_LOGIN_ENABLED')
if (enabled && !fullRollout && testUserIds.length === 0) {
missing.push('YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS')
}
return {
configured: missing.length === 0,
enabled,
rollout_mode: fullRollout ? 'full' as const : testUserIds.length > 0 ? 'test' as const : 'disabled' as const,
test_user_count: testUserIds.length,
missing
}
}
export const isNotificationCenterLoginAllowedForUser = (dingtalkUserId: string) => {
const status = getNotificationCenterLoginConfigStatus()
if (!status.configured) return false
if (status.rollout_mode === 'full') return true
return notificationCenterLoginTestUserIds().includes(String(dingtalkUserId || '').trim())
}
const sha256 = (value: string) => crypto.createHash('sha256').update(value).digest('hex')
export const createNotificationCenterSignature = (input: {
@ -163,8 +198,69 @@ const validateDelivery = (data: Record<string, any>) => {
const buildActionUrl = (actionUrl: string) => {
const publicBaseUrl = String(process.env.DINGTALK_PUBLIC_BASE_URL || '').trim().replace(/\/$/, '')
const safePath = actionUrl.startsWith('/') && !actionUrl.startsWith('//') ? actionUrl : '/'
return `${publicBaseUrl}/dingtalk-entry?redirect=${encodeURIComponent(safePath)}`
const safePath = normalizeInternalRedirect(actionUrl)
let baseUrl: URL
try {
baseUrl = new URL(publicBaseUrl)
} catch {
throw new NotificationCenterError('固定资产公开地址格式无效', 'PUBLIC_BASE_URL_INVALID', true)
}
if (
baseUrl.protocol !== 'https:' ||
baseUrl.username ||
baseUrl.password ||
baseUrl.search ||
baseUrl.hash
) {
throw new NotificationCenterError('固定资产公开地址必须为不含凭证和查询参数的HTTPS地址', 'PUBLIC_BASE_URL_INVALID', true)
}
return new URL(safePath, `${baseUrl.toString().replace(/\/$/, '')}/`).toString()
}
export const buildYouyidianLoginEntryUrl = (redirectPath: unknown) => {
const value = config()
const status = getNotificationCenterLoginConfigStatus()
if (!status.configured) {
throw new NotificationCenterError(
`由壹点通知中心配置不完整: ${status.missing.join(', ')}`,
'CONFIG_INCOMPLETE',
true
)
}
const safeRedirect = normalizeInternalRedirect(redirectPath)
const query = new URLSearchParams({
projectCode: value.projectCode,
redirectPath: safeRedirect
})
return `${value.baseUrl}/v1/auth/h5-entry?${query.toString()}`
}
export type NotificationCenterLoginTicket = {
dingtalkUserId: string
redirectPath: string
}
export const redeemYouyidianLoginTicket = async (ticket: string): Promise<NotificationCenterLoginTicket> => {
const status = getNotificationCenterLoginConfigStatus()
if (!status.configured) {
throw new NotificationCenterError(
`由壹点通知中心免登配置未启用: ${status.missing.join(', ')}`,
'CONFIG_INCOMPLETE',
true
)
}
const data = await request('/v1/auth/tickets/redeem', 'POST', { ticket })
const dingtalkUserId = String(data.dingtalkUserId || '').trim()
if (!dingtalkUserId) {
throw new NotificationCenterError('由壹点通知中心返回了无效的登录身份', 'INVALID_RESPONSE', true)
}
let redirectPath: string
try {
redirectPath = normalizeInternalRedirect(data.redirectPath)
} catch {
throw new NotificationCenterError('由壹点通知中心返回了无效的跳转地址', 'INVALID_RESPONSE', true)
}
return { dingtalkUserId, redirectPath }
}
export const submitYouyidianNotification = async (input: {

View File

@ -8,6 +8,8 @@ declare module 'express-session' {
store?: Store
cookie?: {
secure?: boolean
httpOnly?: boolean
sameSite?: boolean | 'lax' | 'strict' | 'none'
maxAge?: number
}
}

View File

@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { isCorsOriginAllowed, isUnsafeRequestOriginAllowed } from '../corsPolicy'
test('生产环境只允许固定资产源和显式白名单进行跨源凭证请求', () => {
const env = {
NODE_ENV: 'production',
DINGTALK_PUBLIC_BASE_URL: 'https://asset.example.com',
CORS_ALLOWED_ORIGINS: 'https://admin.example.com'
}
assert.equal(isCorsOriginAllowed(undefined, env), true)
assert.equal(isCorsOriginAllowed('https://asset.example.com', env), true)
assert.equal(isCorsOriginAllowed('https://admin.example.com', env), true)
assert.equal(isCorsOriginAllowed('https://sc.example.com', env), false)
assert.equal(isCorsOriginAllowed('https://evil.example.com', env), false)
})
test('开发环境默认允许本地Vite,但不反射任意来源', () => {
const env = { NODE_ENV: 'development' }
assert.equal(isCorsOriginAllowed('http://localhost:5173', env), true)
assert.equal(isCorsOriginAllowed('https://evil.example.com', env), false)
})
test('跨源写操作默认拒绝,只单独放行通知中心票据回调', () => {
const env = {
NODE_ENV: 'production',
DINGTALK_PUBLIC_BASE_URL: 'https://asset.example.com',
YYD_NOTIFICATION_CENTER_URL: 'https://sc.example.com/notification-center'
}
assert.equal(isUnsafeRequestOriginAllowed({
method: 'POST',
origin: 'https://asset.example.com',
requestOrigin: 'https://asset.example.com',
path: '/api/equipment'
}, env), true)
assert.equal(isUnsafeRequestOriginAllowed({
method: 'POST',
origin: 'https://sc.example.com',
requestOrigin: 'https://asset.example.com',
path: '/api/equipment'
}, env), false)
assert.equal(isUnsafeRequestOriginAllowed({
method: 'POST',
origin: 'https://sc.example.com',
requestOrigin: 'https://asset.example.com',
path: '/api/dingtalk/broker-login'
}, env), true)
assert.equal(isUnsafeRequestOriginAllowed({
method: 'DELETE',
origin: 'https://evil.example.com',
requestOrigin: 'https://asset.example.com',
path: '/api/equipment/1'
}, env), false)
})

View File

@ -0,0 +1,49 @@
const parseOrigin = (value: string) => {
try {
const url = new URL(value)
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null
return url.origin
} catch {
return null
}
}
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS'])
export const getAllowedCorsOrigins = (env: NodeJS.ProcessEnv = process.env) => {
const configured = String(env.CORS_ALLOWED_ORIGINS || '')
.split(',')
.map((item) => parseOrigin(item.trim()))
.filter((item): item is string => Boolean(item))
const publicOrigin = parseOrigin(String(env.DINGTALK_PUBLIC_BASE_URL || '').trim())
if (publicOrigin) configured.push(publicOrigin)
if (String(env.NODE_ENV || '').trim().toLowerCase() !== 'production') {
configured.push('http://localhost:5173', 'http://127.0.0.1:5173')
}
return new Set(configured)
}
export const isCorsOriginAllowed = (
origin: string | undefined,
env: NodeJS.ProcessEnv = process.env
) => !origin || getAllowedCorsOrigins(env).has(origin)
export const isUnsafeRequestOriginAllowed = (input: {
method: string
origin?: string
requestOrigin: string
path: string
}, env: NodeJS.ProcessEnv = process.env) => {
if (safeMethods.has(String(input.method || '').toUpperCase())) return true
if (!input.origin) return true
const origin = parseOrigin(input.origin)
const requestOrigin = parseOrigin(input.requestOrigin)
if (!origin || !requestOrigin) return false
if (origin === requestOrigin || getAllowedCorsOrigins(env).has(origin)) return true
const notificationCenterOrigin = parseOrigin(String(env.YYD_NOTIFICATION_CENTER_URL || '').trim())
return input.path === '/api/dingtalk/broker-login' && origin === notificationCenterOrigin
}

View File

@ -26,3 +26,12 @@ export const normalizeInternalRedirect = (value: unknown) => {
}
return `${url.pathname}${url.search}${url.hash}`
}
export const buildDingTalkBrokerCompletionPath = (redirectPath: unknown) => {
const safeRedirect = normalizeInternalRedirect(redirectPath)
const query = new URLSearchParams({
broker: 'complete',
redirect: safeRedirect
})
return `/dingtalk-entry?${query.toString()}`
}

View File

@ -34,6 +34,8 @@ services:
environment:
DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD:-change-me-local-password}@mysql:3306/${MYSQL_DATABASE:-equipment_management}?charset=utf8mb4
SESSION_SECRET: ${SESSION_SECRET:-change-me-session-secret}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
AUTH_MODE: ${AUTH_MODE:-local}
PORT: ${PORT:-3000}
SQLSERVER_HOST: ${SQLSERVER_HOST:-}
@ -63,6 +65,9 @@ services:
YYD_NOTIFICATION_CENTER_PROJECT_SECRET: ${YYD_NOTIFICATION_CENTER_PROJECT_SECRET:-}
YYD_NOTIFICATION_CENTER_TEST_USER_IDS: ${YYD_NOTIFICATION_CENTER_TEST_USER_IDS:-}
YYD_NOTIFICATION_CENTER_FULL_ROLLOUT: ${YYD_NOTIFICATION_CENTER_FULL_ROLLOUT:-false}
YYD_NOTIFICATION_CENTER_LOGIN_ENABLED: ${YYD_NOTIFICATION_CENTER_LOGIN_ENABLED:-false}
YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS: ${YYD_NOTIFICATION_CENTER_LOGIN_TEST_USER_IDS:-}
YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT: ${YYD_NOTIFICATION_CENTER_LOGIN_FULL_ROLLOUT:-false}
DOWNLOADS_DIR: /app/downloads
depends_on:
mysql:

View File

@ -50,6 +50,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
@ -113,6 +114,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
@ -176,6 +178,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}

View File

@ -3,6 +3,8 @@ import type { ApiResponse, User } from '@/types'
export interface DingTalkPublicConfig {
configured: boolean
mode?: 'work_notice' | 'notification_center'
loginEntryUrl?: string
clientId?: string
client_id?: string
corpId?: string
@ -26,6 +28,11 @@ export interface DingTalkDeliveryStatus {
send_enabled?: boolean
enabled?: boolean
missing?: string[]
login_configured?: boolean
login_enabled?: boolean
login_rollout_mode?: 'disabled' | 'test' | 'full'
login_test_user_count?: number
login_missing?: string[]
pending: number
failed: number
sent?: number
@ -34,8 +41,10 @@ export interface DingTalkDeliveryStatus {
error_summary?: string[]
}
export const getDingTalkPublicConfig = async () => {
const response = await api.get<ApiResponse<DingTalkPublicConfig>>('/dingtalk/public-config')
export const getDingTalkPublicConfig = async (redirectPath: string) => {
const response = await api.get<ApiResponse<DingTalkPublicConfig>>('/dingtalk/public-config', {
params: { redirectPath }
})
return response.data
}

View File

@ -93,11 +93,23 @@ const startLogin = async () => {
const requestedPath = sanitizeRedirectPath(route.query.redirect || route.query.redirectPath)
try {
if (route.query.broker === 'complete') {
statusText.value = '身份已确认,正在进入系统...'
await authStore.refreshUser()
authStore.setToken('session')
await router.replace(requestedPath)
return
}
if (!/DingTalk/i.test(navigator.userAgent)) {
throw new Error('请从钉钉工作通知或固定资产微应用中打开此页面')
}
const configResponse = await getDingTalkPublicConfig()
const configResponse = await getDingTalkPublicConfig(requestedPath)
const config = configResponse.data
if (config?.mode === 'notification_center' && config.loginEntryUrl) {
statusText.value = '正在进入由壹点统一身份验证...'
window.location.replace(config.loginEntryUrl)
return
}
const clientId = config?.clientId || config?.client_id
const corpId = config?.corpId || config?.corp_id
if (!config?.configured || !clientId || !corpId) throw new Error('固定资产钉钉应用尚未配置完整')

View File

@ -27,6 +27,13 @@
<p class="page-meta__label">失败</p>
<p class="page-meta__value text-status-error">{{ status?.failed || 0 }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">钉钉免登</p>
<p class="page-meta__value text-base" :class="status?.login_configured ? 'text-status-success' : 'text-status-warning'">
{{ status?.login_configured ? '可用' : '关闭' }}
</p>
<p class="page-meta__subvalue">{{ getLoginText() }}</p>
</div>
</div>
</section>
@ -152,9 +159,11 @@ const loadData = async () => {
}
const retry = async (item: any) => {
const message = item.provider_type === 'notification_center'
? '将通过通知中心创建一次新的人工补发;已送达的旧消息不会撤回。确定继续吗?'
: '确定重新投递这条钉钉通知吗?'
const message = item.last_error_code === 'DELIVERY_OUTCOME_UNKNOWN'
? '钉钉是否已经收到这条消息无法自动确认。请先到收件人的钉钉单聊中核验;确认未收到后再补发,否则可能产生重复消息。仍要补发吗?'
: item.provider_type === 'notification_center'
? '将通过通知中心创建一次新的人工补发;已送达的旧消息不会撤回。确定继续吗?'
: '确定重新投递这条钉钉通知吗?'
if (!confirm(message)) return
try {
await retryDingTalkDelivery(Number(item.id))
@ -184,6 +193,12 @@ const getChannelText = () => {
return '通知中心 · 灰度未启用'
}
const getLoginText = () => {
if (status.value?.login_rollout_mode === 'full') return '通知中心 · 全量'
if (status.value?.login_rollout_mode === 'test') return `通知中心 · 灰度 ${status.value.login_test_user_count || 0} 人`
return '独立开关未启用'
}
const getDeliveryBadgeClass = (value?: string) => {
if (value === 'failed') return 'badge-error'
if (value === 'sent' || value === 'read') return 'badge-success'