Compare commits

...

2 Commits

Author SHA1 Message Date
aiyimickey e1fd098a97 feat: monitor permissions sync + universal docker exec for role writes + build freshness check
- role-manager.tsx: 添加同步到所有站点按钮 + 单用户单站点同步 + 同步结果显示详情
- roles/route.ts: 返回 display_name 修复下拉框显示 ()
- user-roles/route.ts: PUT 统一用 docker exec -i 写所有站点(绕过 SQLite 锁)
- sync-users/route.ts: 新增同步 API,统一 docker exec 写所有站点
- deploy-ai.sh: 构建新鲜度自动检查
- deploy-monitor.sh: 同上
2026-07-08 10:35:59 +08:00
aiyimickey 7365c4e3b8 fix: middleware publicPaths + /api/internal/users for OA monitor integration 2026-07-08 09:13:51 +08:00
4 changed files with 140 additions and 18 deletions

View File

@ -33,6 +33,7 @@ export default function RoleManager({ setResult, onUserUpdated }: Props) {
const [editingDisplayName, setEditingDisplayName] = useState<string | null>(null) const [editingDisplayName, setEditingDisplayName] = useState<string | null>(null)
const [editDisplayNameValue, setEditDisplayNameValue] = useState('') const [editDisplayNameValue, setEditDisplayNameValue] = useState('')
const [savingDisplayName, setSavingDisplayName] = useState(false) const [savingDisplayName, setSavingDisplayName] = useState(false)
const [syncing, setSyncing] = useState<string | null>(null) // username being synced, or 'all'
const fetchRoleData = useCallback(async () => { const fetchRoleData = useCallback(async () => {
try { try {
@ -99,6 +100,23 @@ export default function RoleManager({ setResult, onUserUpdated }: Props) {
setPending({}); if (fail === 0) fetchRoleData(); setSaving(false) setPending({}); if (fail === 0) fetchRoleData(); setSaving(false)
} }
async function syncUsers(usernames: string[], targetSite?: string) {
setSyncing(usernames.length === 1 ? usernames[0] : 'all')
try {
const res = await fetch('/api/admin/sync-users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ usernames, targetSite }) })
const d = await res.json()
if (res.ok) {
fetchRoleData()
const failed = Object.entries(d.results || {}).flatMap(([site, users]: [string, any]) =>
Object.entries(users).filter(([, ok]) => !ok).map(([u]) => `${u}${site}`)
)
setResult(failed.length === 0, failed.length > 0 ? `${failed.join(', ')} 同步失败` : '同步完成')
}
else setResult(false, d.error || '同步失败')
} catch { setResult(false, '网络错误') }
finally { setSyncing(null) }
}
if (!roleData) return <p style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>...</p> if (!roleData) return <p style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>...</p>
const userMap = new Map<string, { displayName: string; email: string; assetsRole: string; issueRole: string; monitorRole: string }>() const userMap = new Map<string, { displayName: string; email: string; assetsRole: string; issueRole: string; monitorRole: string }>()
@ -112,9 +130,14 @@ export default function RoleManager({ setResult, onUserUpdated }: Props) {
<div> <div>
<div style={ss.bar}> <div style={ss.bar}>
<p style={ss.hint}>{changed > 0 && <span style={{ color: '#d97706', fontWeight: 700, marginLeft: 10 }}>{changed} </span>}</p> <p style={ss.hint}>{changed > 0 && <span style={{ color: '#d97706', fontWeight: 700, marginLeft: 10 }}>{changed} </span>}</p>
<button onClick={handleSave} disabled={changed === 0 || saving} style={ss.saveBtn(changed > 0)}> <div style={{ display: 'flex', gap: 8 }}>
{saving ? '保存中...' : `保存修改${changed > 0 ? ` (${changed})` : ''}`} <button onClick={() => syncUsers(users.map(([u]) => u))} disabled={syncing !== null} style={ss.saveBtn(false)}>
</button> {syncing === 'all' ? '同步中...' : '同步到所有站点'}
</button>
<button onClick={handleSave} disabled={changed === 0 || saving} style={ss.saveBtn(changed > 0)}>
{saving ? '保存中...' : `保存修改${changed > 0 ? ` (${changed})` : ''}`}
</button>
</div>
</div> </div>
<div style={{ overflowX: 'auto' }}><table style={ss.table}> <div style={{ overflowX: 'auto' }}><table style={ss.table}>
@ -161,9 +184,9 @@ export default function RoleManager({ setResult, onUserUpdated }: Props) {
</span> </span>
)} )}
</td> </td>
<td style={ss.td}><RoleCell site="assets" username={uname} originalRole={info.assetsRole} roles={roleData.assetsRoles} pending={pending} onSelect={handleSelect} getCurrentRole={getCurrentRole} /></td> <td style={ss.td}><RoleCell site="assets" username={uname} originalRole={info.assetsRole} roles={roleData.assetsRoles} pending={pending} onSelect={handleSelect} getCurrentRole={getCurrentRole} syncing={syncing} onSync={syncUsers} /></td>
<td style={ss.td}><RoleCell site="issue" username={uname} originalRole={info.issueRole} roles={roleData.issueRoles} pending={pending} onSelect={handleSelect} getCurrentRole={getCurrentRole} /></td> <td style={ss.td}><RoleCell site="issue" username={uname} originalRole={info.issueRole} roles={roleData.issueRoles} pending={pending} onSelect={handleSelect} getCurrentRole={getCurrentRole} syncing={syncing} onSync={syncUsers} /></td>
<td style={ss.td}><RoleCell site="monitor" username={uname} originalRole={info.monitorRole} roles={roleData.monitorRoles} pending={pending} onSelect={handleSelect} getCurrentRole={getCurrentRole} /></td> <td style={ss.td}><RoleCell site="monitor" username={uname} originalRole={info.monitorRole} roles={roleData.monitorRoles} pending={pending} onSelect={handleSelect} getCurrentRole={getCurrentRole} syncing={syncing} onSync={syncUsers} /></td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -172,17 +195,27 @@ export default function RoleManager({ setResult, onUserUpdated }: Props) {
) )
} }
function RoleCell({ site, username, originalRole, roles, pending, onSelect, getCurrentRole }: { function RoleCell({ site, username, originalRole, roles, pending, onSelect, getCurrentRole, syncing, onSync }: {
site: string; username: string; originalRole: string; roles: string[] site: string; username: string; originalRole: string; roles: string[]
pending: Record<string, { site: string; newRole: string }> pending: Record<string, { site: string; newRole: string }>
onSelect: (site: string, username: string, newRole: string, originalRole: string) => void onSelect: (site: string, username: string, newRole: string, originalRole: string) => void
getCurrentRole: (site: string, username: string, originalRole: string) => string getCurrentRole: (site: string, username: string, originalRole: string) => string
syncing: string | null; onSync: (usernames: string[], site?: string) => void
}) { }) {
const isReserved = username === 'admin' || username === 'localadmin' const isReserved = username === 'admin' || username === 'localadmin'
const changed = !!pending[`${site}:${username}`] const changed = !!pending[`${site}:${username}`]
const currentRole = getCurrentRole(site, username, originalRole) const currentRole = getCurrentRole(site, username, originalRole)
if (isReserved) return <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{currentRole}</span> if (isReserved) return <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{currentRole}</span>
if (originalRole === '—' && !changed) return <span style={{ fontSize: 11, color: 'var(--text-muted)' }}></span> if (originalRole === '—' && !changed) return (
<span style={{ fontSize: 11 }}>
<span style={{ color: 'var(--text-muted)' }}></span>
{' '}
<button onClick={() => onSync([username], site)} disabled={syncing === username}
style={{ background: 'none', border: 'none', color: '#2563eb', fontSize: 11, cursor: syncing === username ? 'not-allowed' : 'pointer', padding: 0, textDecoration: 'underline', opacity: syncing === username ? 0.5 : 1 }}>
{syncing === username ? '同步中...' : '同步'}
</button>
</span>
)
return ( return (
<select value={currentRole} onChange={e => onSelect(site, username, e.target.value, originalRole)} style={ss.roleSelect(changed)}> <select value={currentRole} onChange={e => onSelect(site, username, e.target.value, originalRole)} style={ss.roleSelect(changed)}>
{roles.map(r => <option key={r} value={r}>{r}</option>)} {roles.map(r => <option key={r} value={r}>{r}</option>)}

View File

@ -5,14 +5,24 @@ import { isLldapAdmin } from '@/lib/ldap'
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026' const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
async function fetchRoles(siteUrl: string): Promise<string[]> { // 角色中文显示名映射
const ROLE_LABELS: Record<string, string> = {
admin: '管理员', editor: '编辑者', viewer: '观察者',
}
interface RoleInfo { name: string; display_name: string }
async function fetchRoles(siteUrl: string): Promise<RoleInfo[]> {
try { try {
const res = await fetch(`${siteUrl}/api/internal/roles`, { const res = await fetch(`${siteUrl}/api/internal/roles`, {
headers: { 'x-internal-key': INTERNAL_KEY }, headers: { 'x-internal-key': INTERNAL_KEY },
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}) })
const data = await res.json() const data = await res.json()
return (data.roles || []).map((r: { name: string }) => r.name) return (data.roles || []).map((r: { name: string }) => ({
name: r.name,
display_name: ROLE_LABELS[r.name] || r.name,
}))
} catch { return [] } } catch { return [] }
} }
@ -27,9 +37,9 @@ export async function GET() {
const I_URL = process.env.ISSUE_INTERNAL_URL || 'http://localhost:6176' const I_URL = process.env.ISSUE_INTERNAL_URL || 'http://localhost:6176'
const M_URL = process.env.MONITOR_INTERNAL_URL || 'http://localhost:6181' const M_URL = process.env.MONITOR_INTERNAL_URL || 'http://localhost:6181'
const [assetsRoles, issueRoles, monitorRoles] = await Promise.all([ const [assets, issue, monitor] = await Promise.all([
fetchRoles(A_URL), fetchRoles(I_URL), fetchRoles(M_URL), fetchRoles(A_URL), fetchRoles(I_URL), fetchRoles(M_URL),
]) ])
return NextResponse.json({ assets: assetsRoles, issue: issueRoles, monitor: monitorRoles }) return NextResponse.json({ assets, issue, monitor })
} }

View File

@ -0,0 +1,67 @@
// POST /api/admin/sync-users — 将用户同步到指定站点docker exec 写 DB
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { execFileSync } from 'child_process'
import { verifySharedJwt } from '@/lib/jwt'
import { isLldapAdmin } from '@/lib/ldap'
import { queryLldap, esc } from '@/lib/lldap-db'
const SITES: Record<string, [string, string]> = {
assets: ['assets-ai', '/app/data/assets.db'],
issue: ['issue-ai', '/app/data/issue.db'],
monitor: ['monitor-ai', '/app/data/monitor.db'],
}
function syncToSite(container: string, dbPath: string, username: string, displayName: string, email: string): boolean {
try {
const su = esc(username); const sd = esc(displayName); const se = esc(email)
const d = new Date()
const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
execFileSync('docker', ['exec', '-i', container, 'sqlite3', dbPath], {
input: `INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES ('${su}', '${sd}', '${se}', 'viewer', 1, '${now}', '${now}');`,
timeout: 5000,
})
return true
} catch { return false }
}
export async function POST(request: Request) {
const cookieStore = await cookies()
const token = cookieStore.get('tlyq_session')?.value
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
const session = verifySharedJwt(token)
if (!session || !(await isLldapAdmin(session.username))) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { usernames, targetSite } = await request.json()
if (!usernames || !Array.isArray(usernames) || usernames.length === 0) {
return NextResponse.json({ error: '请指定要同步的用户' }, { status: 400 })
}
// 从 LLDAP 获取用户信息
const userInfo: Record<string, { displayName: string; email: string }> = {}
try {
const safeNames = usernames.map(u => `'${esc(u)}'`).join(',')
const out = queryLldap(`SELECT user_id, display_name, email FROM users WHERE user_id IN (${safeNames})`)
out.split('\n').filter(Boolean).forEach(line => {
const [uid, dn, em] = line.split('|')
userInfo[uid] = { displayName: dn || uid, email: em || '' }
})
} catch {}
const sites = targetSite && SITES[targetSite] ? { [targetSite]: SITES[targetSite] }
: targetSite ? null : SITES
if (!sites) return NextResponse.json({ error: '无效的站点' }, { status: 400 })
const results: Record<string, Record<string, boolean>> = {}
for (const [site, [cName, cPath]] of Object.entries(sites)) {
results[site] = {}
for (const username of usernames) {
const info = userInfo[username] || { displayName: username, email: '' }
results[site][username] = syncToSite(cName, cPath, username, info.displayName, info.email)
}
}
return NextResponse.json({ success: true, results })
}

View File

@ -65,11 +65,17 @@ export async function GET() {
fetchRoles(A_URL), fetchRoles(I_URL), fetchRoles(M_URL), fetchRoles(A_URL), fetchRoles(I_URL), fetchRoles(M_URL),
]) ])
const [assetsUsers, issueUsers, monitorUsers] = await Promise.all([ // monitor 走 APIDB 被 monitor 进程锁不可直连assets/issue 直连 SQLite
const [assetsUsers, issueUsers] = await Promise.all([
getSiteUsers(A_DB, assetsRoles), getSiteUsers(A_DB, assetsRoles),
getSiteUsers(I_DB, issueRoles), getSiteUsers(I_DB, issueRoles),
getSiteUsers(M_DB, monitorRoles),
]) ])
let monitorUsers: { username: string; display_name: string; role: string }[] = []
try {
const mRes = await fetch(`${M_URL}/api/internal/users`, { headers: { 'x-internal-key': INTERNAL_KEY }, signal: AbortSignal.timeout(5000) })
const mData = await mRes.json()
monitorUsers = (mData.users || []).map((u: any) => ({ username: u.username, display_name: u.display_name || u.username, role: monitorRoles.includes(u.role) ? u.role : 'viewer' }))
} catch { /* monitor 不可达时使用空列表 */ }
let emails: Record<string, string> = {} let emails: Record<string, string> = {}
try { try {
@ -102,10 +108,16 @@ export async function PUT(request: Request) {
const roles = await fetchRoles(siteUrl(site)) const roles = await fetchRoles(siteUrl(site))
if (!roles.includes(role)) return NextResponse.json({ error: '无效的角色' }, { status: 400 }) if (!roles.includes(role)) return NextResponse.json({ error: '无效的角色' }, { status: 400 })
execFileSync('sqlite3', [siteDb(site)], { // 统一通过 docker exec -i 写各站点 DB直连 SQLite 在不同容器间始终 readonly
input: `UPDATE users SET role='${role}', updated_at=datetime('now', '+8 hours') WHERE username='${username}';`, const containers: Record<string, [string, string]> = {
timeout: 3000, assets: ['assets-ai', '/app/data/assets.db'],
}) issue: ['issue-ai', '/app/data/issue.db'],
monitor: ['monitor-ai', '/app/data/monitor.db'],
}
const [cName, cPath] = containers[site] || [null, null]
if (!cName) return NextResponse.json({ error: '未知站点' }, { status: 400 })
const sql = `UPDATE users SET role='${role}', updated_at=datetime('now', '+8 hours') WHERE username='${username}';`
execFileSync('docker', ['exec', '-i', cName, 'sqlite3', cPath], { input: sql, timeout: 5000 })
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch { } catch {