organizations.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
  2. import type pg from 'pg'
  3. import { randomBytes, randomUUID, createHash } from 'node:crypto'
  4. import { z } from 'zod'
  5. type Pool = pg.Pool
  6. type Deps = {
  7. pool: Pool
  8. auth: (req: FastifyRequest, reply: FastifyReply) => Promise<unknown>
  9. adminAuth: (req: FastifyRequest, reply: FastifyReply) => Promise<unknown>
  10. hashPassword: (password: string) => Promise<string>
  11. mapUser: (row: any) => any
  12. }
  13. const sha = (value: string) => createHash('sha256').update(value).digest('hex')
  14. const uid = (req: FastifyRequest) => (req as any).user.id as string
  15. const params = (req: FastifyRequest) => req.params as Record<string, string>
  16. const json = (value: unknown) => JSON.stringify(value)
  17. export function registerOrganizations(app: FastifyInstance, { pool, auth, adminAuth, hashPassword, mapUser }: Deps) {
  18. const membership = async (organizationId: string, userId: string) =>
  19. (await pool.query('SELECT role FROM organization_members WHERE organization_id = $1 AND user_id = $2', [organizationId, userId])).rows[0] as { role: 'MEMBER' | 'MANAGER' } | undefined
  20. const org = async (id: string) => (await pool.query('SELECT * FROM organizations WHERE id = $1', [id])).rows[0]
  21. const manager = async (req: FastifyRequest, reply: FastifyReply, organizationId: string) => {
  22. const member = await membership(organizationId, uid(req))
  23. if (member?.role !== 'MANAGER') {
  24. reply.code(403).send({ message: '没有该组织的管理权限' })
  25. return false
  26. }
  27. return true
  28. }
  29. const event = (organizationId: string | null, actorId: string, action: string, details: unknown, subjectUserId: string | null = null) =>
  30. pool.query(`INSERT INTO audit_events(id, organization_id, actor_id, admin_actor_id, subject_user_id, action, details)
  31. VALUES($1,$2,$3,$4,$5,$6,$7)`, [randomUUID(), organizationId, ['ORGANIZATION_CREATED','ORGANIZATION_UPDATED','MEMBER_ROLE_SET','MEMBER_REMOVED','MEMBER_APPROVED','MEMBER_REJECTED','ORGANIZATION_POINTS_ADJUSTED'].includes(action) ? null : actorId,
  32. ['ORGANIZATION_CREATED','ORGANIZATION_UPDATED','MEMBER_ROLE_SET','MEMBER_REMOVED','MEMBER_APPROVED','MEMBER_REJECTED','ORGANIZATION_POINTS_ADJUSTED'].includes(action) ? actorId : null,
  33. subjectUserId, action, json(details)])
  34. app.get('/api/me/organizations', { preHandler: auth }, async (req) => {
  35. const { rows } = await pool.query(`SELECT o.id, o.slug, o.name, o.is_active, o.points_balance, m.role
  36. FROM organization_members m JOIN organizations o ON o.id = m.organization_id
  37. WHERE m.user_id = $1 ORDER BY o.name`, [uid(req)])
  38. return { organizations: rows.map((r) => ({ id: r.id, slug: r.slug, name: r.name, isActive: r.is_active, pointsBalance: r.points_balance, role: r.role })) }
  39. })
  40. app.get('/api/organizations/:slug', { preHandler: auth }, async (req) => {
  41. const { rows } = await pool.query('SELECT id, name, is_active FROM organizations WHERE slug = $1', [params(req).slug])
  42. return { organization: rows[0] ? { id: rows[0].id, name: rows[0].name, isActive: rows[0].is_active } : null }
  43. })
  44. app.post('/api/organizations/:slug/apply', { preHandler: auth }, async (req, reply) => {
  45. const { rows } = await pool.query('SELECT id FROM organizations WHERE slug = $1 AND is_active=TRUE', [params(req).slug])
  46. if (!rows[0]) return reply.code(404).send({ message: '组织不存在' })
  47. const organizationId = rows[0].id
  48. if (await membership(organizationId, uid(req))) return reply.code(409).send({ message: '您已经是该组织成员' })
  49. await pool.query(`INSERT INTO organization_applications(id, organization_id, user_id, status)
  50. VALUES($1,$2,$3,'PENDING') ON CONFLICT (organization_id,user_id)
  51. DO UPDATE SET status = 'PENDING', reviewed_by = NULL, reviewed_at = NULL, created_at = NOW()`,
  52. [randomUUID(), organizationId, uid(req)])
  53. await event(organizationId, uid(req), 'MEMBERSHIP_APPLIED', {})
  54. return { ok: true }
  55. })
  56. app.post('/api/organizations/join/:token', { preHandler: auth }, async (req, reply) => {
  57. const { rows } = await pool.query('SELECT id FROM organizations WHERE invite_token_hash = $1 AND is_active=TRUE', [sha(params(req).token)])
  58. if (!rows[0]) return reply.code(404).send({ message: '注册链接无效或已停用' })
  59. await pool.query(`INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2)
  60. ON CONFLICT (organization_id,user_id) DO NOTHING`, [rows[0].id, uid(req)])
  61. await pool.query(`UPDATE organization_applications SET status='APPROVED',reviewed_at=NOW()
  62. WHERE organization_id=$1 AND user_id=$2 AND status='PENDING'`, [rows[0].id, uid(req)])
  63. await event(rows[0].id, uid(req), 'INVITE_JOINED', {})
  64. return { organizationId: rows[0].id }
  65. })
  66. app.get('/api/organizations/:id/content', { preHandler: auth }, async (req, reply) => {
  67. const id = params(req).id
  68. const member = await membership(id, uid(req))
  69. if (!member) return reply.code(403).send({ message: '请先加入组织' })
  70. const organization = await org(id)
  71. if (!organization) return reply.code(404).send({ message: '组织不存在' })
  72. const [members, courses] = await Promise.all([
  73. pool.query(`SELECT u.id, u.contact_name, m.role FROM organization_members m
  74. JOIN users u ON u.id=m.user_id WHERE m.organization_id=$1 ORDER BY m.joined_at`, [id]),
  75. pool.query(`SELECT c.id,c.user_id,c.name,c.status,c.created_at,c.updated_at,c.submitted_at,c.completed_at,
  76. u.contact_name AS creator_name FROM courses c JOIN users u ON u.id=c.user_id
  77. WHERE c.organization_id=$1 ORDER BY c.created_at DESC`, [id])
  78. ])
  79. return { organization: { id, slug: organization.slug, name: organization.name, pointsBalance: organization.points_balance, role: member.role }, members: members.rows, courses: courses.rows }
  80. })
  81. app.get('/api/organizations/:id/manage', { preHandler: auth }, async (req, reply) => {
  82. const id = params(req).id
  83. if (!(await manager(req, reply, id))) return
  84. const [members, applications, audit, ledger] = await Promise.all([
  85. pool.query(`SELECT u.id,u.phone,u.contact_name,m.role,m.joined_at FROM organization_members m
  86. JOIN users u ON u.id=m.user_id WHERE m.organization_id=$1 ORDER BY m.joined_at DESC`, [id]),
  87. pool.query(`SELECT a.id,a.status,a.created_at,u.id AS user_id,u.phone,u.contact_name
  88. FROM organization_applications a JOIN users u ON u.id=a.user_id
  89. WHERE a.organization_id=$1 ORDER BY a.created_at DESC`, [id]),
  90. pool.query(`SELECT a.*,u.contact_name AS actor_name FROM audit_events a LEFT JOIN users u ON u.id=a.actor_id
  91. WHERE a.organization_id=$1 ORDER BY a.created_at DESC`, [id]),
  92. pool.query(`SELECT l.*,u.contact_name AS beneficiary_name FROM points_ledger l
  93. LEFT JOIN users u ON u.id=l.beneficiary_user_id
  94. WHERE l.owner_type='ORGANIZATION' AND l.owner_id=$1 ORDER BY l.created_at DESC`, [id])
  95. ])
  96. const organization = await org(id)
  97. return { members: members.rows, applications: applications.rows, audit: audit.rows, ledger: ledger.rows,
  98. inviteActive: Boolean(organization?.invite_token_hash),
  99. inviteToken: organization?.invite_token || null }
  100. })
  101. app.post('/api/organizations/:id/applications/:applicationId/review', { preHandler: auth }, async (req, reply) => {
  102. const id = params(req).id
  103. if (!(await manager(req, reply, id))) return
  104. const parsed = z.object({ approve: z.boolean() }).safeParse(req.body)
  105. if (!parsed.success) return reply.code(400).send({ message: '审批参数不正确' })
  106. const client = await pool.connect()
  107. try {
  108. await client.query('BEGIN')
  109. const application = await client.query(`UPDATE organization_applications SET status=$1,reviewed_by=$2,reviewed_at=NOW()
  110. WHERE id=$3 AND organization_id=$4 AND status='PENDING' RETURNING user_id`,
  111. [parsed.data.approve ? 'APPROVED' : 'REJECTED', uid(req), params(req).applicationId, id])
  112. if (!application.rows[0]) { await client.query('ROLLBACK'); return reply.code(404).send({ message: '待审批申请不存在' }) }
  113. if (parsed.data.approve) await client.query(`INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2)
  114. ON CONFLICT DO NOTHING`, [id, application.rows[0].user_id])
  115. await client.query('COMMIT')
  116. await event(id, uid(req), parsed.data.approve ? 'MEMBER_APPROVED' : 'MEMBER_REJECTED',
  117. { applicationId: params(req).applicationId, before: 'PENDING', after: parsed.data.approve ? 'APPROVED' : 'REJECTED' }, application.rows[0].user_id)
  118. return { ok: true }
  119. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  120. })
  121. app.post('/api/organizations/:id/users', { preHandler: auth }, async (req, reply) => {
  122. const id = params(req).id
  123. if (!(await manager(req, reply, id))) return
  124. if (!(await org(id))?.is_active) return reply.code(409).send({ message: '组织已停用,不能创建新成员' })
  125. const parsed = z.object({ phone: z.string().regex(/^1\d{10}$/), contactName: z.string().trim().min(1).max(80) }).safeParse(req.body)
  126. if (!parsed.success) return reply.code(400).send({ message: '请填写正确的手机号和姓名' })
  127. const client = await pool.connect()
  128. try {
  129. await client.query('BEGIN')
  130. const userId = randomUUID()
  131. const created = await client.query(`INSERT INTO users(id,phone,password_hash,role,contact_name,points_balance,must_change_password)
  132. VALUES($1,$2,$3,'USER',$4,10000,TRUE) RETURNING *`,
  133. [userId, parsed.data.phone, await hashPassword('888888'), parsed.data.contactName])
  134. await client.query('INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2)', [id, userId])
  135. await client.query('COMMIT')
  136. await event(id, uid(req), 'USER_CREATED', { phone: parsed.data.phone, contactName: parsed.data.contactName }, userId)
  137. return reply.code(201).send({ user: mapUser(created.rows[0]), initialPassword: '888888' })
  138. } catch (error: any) {
  139. await client.query('ROLLBACK')
  140. if (error.code === '23505') return reply.code(409).send({ message: '手机号已注册,请让用户通过组织链接加入' })
  141. throw error
  142. } finally { client.release() }
  143. })
  144. app.patch('/api/organizations/:id/members/:userId', { preHandler: auth }, async (req, reply) => {
  145. const id = params(req).id
  146. if (!(await manager(req, reply, id))) return
  147. const parsed = z.object({ contactName: z.string().trim().min(1, '请填写姓名').max(80, '姓名不能超过 80 个字') }).safeParse(req.body)
  148. if (!parsed.success) return reply.code(400).send({ message: parsed.error.issues[0].message })
  149. const member = await membership(id, params(req).userId)
  150. if (!member) return reply.code(404).send({ message: '组织成员不存在' })
  151. const { rows } = await pool.query('UPDATE users SET contact_name=$1,updated_at=NOW() WHERE id=$2 RETURNING *', [parsed.data.contactName, params(req).userId])
  152. await event(id, uid(req), 'MEMBER_NAME_UPDATED', { contactName: parsed.data.contactName }, params(req).userId)
  153. return { user: mapUser(rows[0]) }
  154. })
  155. app.post('/api/organizations/:id/invite', { preHandler: auth }, async (req, reply) => {
  156. const id = params(req).id
  157. if (!(await manager(req, reply, id))) return
  158. if (!(await org(id))?.is_active) return reply.code(409).send({ message: '组织已停用,不能生成注册链接' })
  159. const token = randomBytes(24).toString('hex')
  160. await pool.query('UPDATE organizations SET invite_token_hash=$1,invite_token=$2,updated_at=NOW() WHERE id=$3', [sha(token), token, id])
  161. await event(id, uid(req), 'INVITE_ROTATED', {})
  162. return { token }
  163. })
  164. app.delete('/api/organizations/:id/invite', { preHandler: auth }, async (req, reply) => {
  165. const id = params(req).id
  166. if (!(await manager(req, reply, id))) return
  167. await pool.query('UPDATE organizations SET invite_token_hash=NULL,invite_token=NULL,updated_at=NOW() WHERE id=$1', [id])
  168. await event(id, uid(req), 'INVITE_DISABLED', {})
  169. return { ok: true }
  170. })
  171. app.get('/api/organizations/:id/points', { preHandler: auth }, async (req, reply) => {
  172. const id = params(req).id
  173. const member = await membership(id, uid(req))
  174. if (!member) return reply.code(403).send({ message: '请先加入组织' })
  175. const { rows: [organization] } = await pool.query('SELECT points_balance FROM organizations WHERE id=$1', [id])
  176. const { rows } = await pool.query(`SELECT l.id,l.course_id,COALESCE(c.name,l.course_name_snapshot) AS course_name,l.amount,l.reason,l.created_at
  177. FROM points_ledger l LEFT JOIN courses c ON c.id=l.course_id
  178. WHERE l.owner_type='ORGANIZATION' AND l.owner_id=$1 ${member.role === 'MANAGER' ? '' : 'AND l.beneficiary_user_id=$2'}
  179. ORDER BY l.created_at DESC`, member.role === 'MANAGER' ? [id] : [id, uid(req)])
  180. return { balance: organization?.points_balance ?? 0, transactions: rows }
  181. })
  182. app.get('/api/admin/organizations', { preHandler: adminAuth }, async () => {
  183. const { rows } = await pool.query(`SELECT o.*,COUNT(m.user_id)::int AS members_count FROM organizations o
  184. LEFT JOIN organization_members m ON m.organization_id=o.id GROUP BY o.id ORDER BY o.created_at DESC`)
  185. return { organizations: rows }
  186. })
  187. app.post('/api/admin/organizations', { preHandler: adminAuth }, async (req, reply) => {
  188. const parsed = z.object({ name: z.string().trim().min(1).max(120), slug: z.string().regex(/^[a-z0-9-]{3,80}$/) }).safeParse(req.body)
  189. if (!parsed.success) return reply.code(400).send({ message: '请填写名称和英文网址标识' })
  190. try {
  191. const { rows } = await pool.query('INSERT INTO organizations(id,name,slug) VALUES($1,$2,$3) RETURNING *',
  192. [randomUUID(), parsed.data.name, parsed.data.slug])
  193. await event(rows[0].id, uid(req), 'ORGANIZATION_CREATED', { name: rows[0].name })
  194. return reply.code(201).send({ organization: rows[0] })
  195. } catch (error: any) {
  196. if (error.code === '23505') return reply.code(409).send({ message: '网址标识已存在' })
  197. throw error
  198. }
  199. })
  200. app.get('/api/admin/organizations/:id', { preHandler: adminAuth }, async (req, reply) => {
  201. const id = params(req).id
  202. const organization = await org(id)
  203. if (!organization) return reply.code(404).send({ message: '组织不存在' })
  204. const [members, applications, ledger, audit, courses] = await Promise.all([
  205. pool.query(`SELECT u.id,u.phone,u.contact_name,m.role FROM organization_members m JOIN users u ON u.id=m.user_id
  206. WHERE m.organization_id=$1 ORDER BY m.joined_at DESC`, [id]),
  207. pool.query(`SELECT a.id,a.status,u.id AS user_id,u.phone,u.contact_name FROM organization_applications a
  208. JOIN users u ON u.id=a.user_id WHERE a.organization_id=$1 ORDER BY a.created_at DESC`, [id]),
  209. pool.query(`SELECT * FROM points_ledger WHERE owner_type='ORGANIZATION' AND owner_id=$1 ORDER BY created_at DESC`, [id]),
  210. pool.query(`SELECT a.*,u.contact_name AS actor_name FROM audit_events a LEFT JOIN users u ON u.id=a.actor_id
  211. WHERE a.organization_id=$1 ORDER BY a.created_at DESC`, [id]),
  212. pool.query(`SELECT c.id,c.name,c.status,c.created_at,c.updated_at,u.contact_name AS creator_name FROM courses c JOIN users u ON u.id=c.user_id
  213. WHERE c.organization_id=$1 ORDER BY c.created_at DESC`, [id])
  214. ])
  215. return { organization, members: members.rows, applications: applications.rows, ledger: ledger.rows, audit: audit.rows, courses: courses.rows }
  216. })
  217. app.patch('/api/admin/organizations/:id', { preHandler: adminAuth }, async (req, reply) => {
  218. const parsed = z.object({
  219. name: z.string().trim().min(1).max(120).optional(),
  220. slug: z.string().trim().regex(/^[a-z0-9-]{3,80}$/, '网址标识需为3-80位小写字母、数字或连字符').optional(),
  221. isActive: z.boolean().optional()
  222. }).refine((v) => v.name !== undefined || v.slug !== undefined || v.isActive !== undefined).safeParse(req.body)
  223. if (!parsed.success) return reply.code(400).send({ message: parsed.error.issues[0]?.message || '组织参数不正确' })
  224. const old = await org(params(req).id)
  225. try {
  226. const { rows } = await pool.query(
  227. 'UPDATE organizations SET name=COALESCE($1,name),slug=COALESCE($2,slug),is_active=COALESCE($3,is_active),updated_at=NOW() WHERE id=$4 RETURNING *',
  228. [parsed.data.name ?? null, parsed.data.slug ?? null, parsed.data.isActive ?? null, params(req).id]
  229. )
  230. if (!rows[0]) return reply.code(404).send({ message: '组织不存在' })
  231. await event(rows[0].id, uid(req), 'ORGANIZATION_UPDATED', {
  232. before: { name: old?.name, slug: old?.slug, isActive: old?.is_active },
  233. after: { name: rows[0].name, slug: rows[0].slug, isActive: rows[0].is_active }
  234. })
  235. return { organization: rows[0] }
  236. } catch (error: any) {
  237. if (error.code === '23505') return reply.code(409).send({ message: '网址标识已存在' })
  238. throw error
  239. }
  240. })
  241. app.put('/api/admin/organizations/:id/members/:userId', { preHandler: adminAuth }, async (req, reply) => {
  242. const parsed = z.object({ role: z.enum(['MEMBER', 'MANAGER']) }).safeParse(req.body)
  243. if (!parsed.success) return reply.code(400).send({ message: '成员角色不正确' })
  244. const id = params(req).id
  245. const previous = await membership(id, params(req).userId)
  246. const { rows } = await pool.query(`INSERT INTO organization_members(organization_id,user_id,role) VALUES($1,$2,$3)
  247. ON CONFLICT (organization_id,user_id) DO UPDATE SET role=EXCLUDED.role RETURNING *`, [id, params(req).userId, parsed.data.role])
  248. await pool.query(`UPDATE organization_applications SET status='APPROVED',reviewed_by_admin=$1,reviewed_at=NOW()
  249. WHERE organization_id=$2 AND user_id=$3 AND status='PENDING'`, [uid(req), id, params(req).userId])
  250. await event(id, uid(req), 'MEMBER_ROLE_SET', { before: previous?.role || null, after: parsed.data.role }, params(req).userId)
  251. return { member: rows[0] }
  252. })
  253. app.delete('/api/admin/organizations/:id/members/:userId', { preHandler: adminAuth }, async (req) => {
  254. const id = params(req).id
  255. await pool.query('DELETE FROM organization_members WHERE organization_id=$1 AND user_id=$2', [id, params(req).userId])
  256. await event(id, uid(req), 'MEMBER_REMOVED', {}, params(req).userId)
  257. return { ok: true }
  258. })
  259. app.post('/api/admin/organizations/:id/applications/:applicationId/review', { preHandler: adminAuth }, async (req, reply) => {
  260. const parsed = z.object({ approve: z.boolean() }).safeParse(req.body)
  261. if (!parsed.success) return reply.code(400).send({ message: '审批参数不正确' })
  262. const id = params(req).id
  263. const client = await pool.connect()
  264. try {
  265. await client.query('BEGIN')
  266. const { rows } = await client.query(`UPDATE organization_applications SET status=$1,reviewed_by_admin=$2,reviewed_at=NOW()
  267. WHERE id=$3 AND organization_id=$4 AND status='PENDING' RETURNING user_id`,
  268. [parsed.data.approve ? 'APPROVED' : 'REJECTED', uid(req), params(req).applicationId, id])
  269. if (!rows[0]) { await client.query('ROLLBACK'); return reply.code(404).send({ message: '待审批申请不存在' }) }
  270. if (parsed.data.approve) await client.query('INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2) ON CONFLICT DO NOTHING', [id, rows[0].user_id])
  271. await client.query('COMMIT')
  272. await event(id, uid(req), parsed.data.approve ? 'MEMBER_APPROVED' : 'MEMBER_REJECTED',
  273. { applicationId: params(req).applicationId, before: 'PENDING', after: parsed.data.approve ? 'APPROVED' : 'REJECTED' }, rows[0].user_id)
  274. return { ok: true }
  275. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  276. })
  277. app.post('/api/admin/organizations/:id/points', { preHandler: adminAuth }, async (req, reply) => {
  278. const parsed = z.object({ amount: z.number().int().refine((n) => n !== 0), reason: z.string().trim().min(1).max(200) }).safeParse(req.body)
  279. if (!parsed.success) return reply.code(400).send({ message: '请填写非零积分和操作原因' })
  280. const id = params(req).id
  281. const client = await pool.connect()
  282. try {
  283. await client.query('BEGIN')
  284. const { rows } = await client.query(`UPDATE organizations SET points_balance=points_balance+$1,updated_at=NOW()
  285. WHERE id=$2 AND points_balance+$1>=0 RETURNING points_balance`, [parsed.data.amount, id])
  286. if (!rows[0]) { await client.query('ROLLBACK'); return reply.code(409).send({ message: '组织不存在或余额不足' }) }
  287. await client.query(`INSERT INTO points_ledger(id,owner_type,owner_id,admin_actor_id,amount,reason)
  288. VALUES($1,'ORGANIZATION',$2,$3,$4,$5)`, [randomUUID(), id, uid(req), parsed.data.amount, parsed.data.reason])
  289. await client.query('COMMIT')
  290. await event(id, uid(req), 'ORGANIZATION_POINTS_ADJUSTED', {
  291. amount: parsed.data.amount, reason: parsed.data.reason,
  292. before: rows[0].points_balance - parsed.data.amount, after: rows[0].points_balance
  293. })
  294. return { balance: rows[0].points_balance }
  295. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  296. })
  297. }