244 lines
13 KiB
Python
244 lines
13 KiB
Python
"""Staff-owned offline ticket lifecycle and optimistic updates."""
|
|
|
|
from contextlib import closing
|
|
from datetime import datetime, datetime as DateTime, timedelta
|
|
from math import ceil
|
|
|
|
from .db import connect
|
|
|
|
STATES = ('pending', 'processing', 'completed')
|
|
MAX_TICKET_ID = 2**63 - 1
|
|
COLUMNS = ('id', 'content', 'phone', 'ctime', 'status', 'owner_username', 'owner_name',
|
|
'note', 'updated_at', 'completed_at', 'version', 'claimed_at', 'claimed_at_estimated')
|
|
EVENT_COLUMNS = ('id', 'ticket_id', 'action', 'actor_username', 'actor_name', 'ctime',
|
|
'old_status', 'new_status', 'old_note', 'new_note')
|
|
PENDING_TIMEOUT = 30 * 60
|
|
PROCESSING_TIMEOUT = 20 * 60
|
|
|
|
|
|
def init_ticket_tables(connection):
|
|
columns = {row[1] for row in connection.execute('PRAGMA table_info(tickets)')}
|
|
additions = {'owner_username':'TEXT', 'owner_name':"TEXT NOT NULL DEFAULT ''",
|
|
'note':"TEXT NOT NULL DEFAULT ''", 'updated_at':'TEXT',
|
|
'completed_at':'TEXT', 'version':'INTEGER NOT NULL DEFAULT 0'}
|
|
for name, declaration in additions.items():
|
|
if name not in columns:
|
|
connection.execute(f'ALTER TABLE tickets ADD COLUMN {name} {declaration}')
|
|
connection.execute("UPDATE tickets SET status='pending' WHERE status IS NULL OR status=''")
|
|
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_status_id ON tickets(status,id)')
|
|
|
|
|
|
def init_ticket_history(connection):
|
|
columns = {row[1] for row in connection.execute('PRAGMA table_info(tickets)')}
|
|
if 'claimed_at' not in columns:
|
|
connection.execute('ALTER TABLE tickets ADD COLUMN claimed_at TEXT')
|
|
if 'claimed_at_estimated' not in columns:
|
|
connection.execute('ALTER TABLE tickets ADD COLUMN claimed_at_estimated INTEGER NOT NULL DEFAULT 0')
|
|
connection.execute('''CREATE TABLE IF NOT EXISTS ticket_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL,
|
|
action TEXT NOT NULL, actor_username TEXT, actor_name TEXT NOT NULL,
|
|
ctime TEXT NOT NULL, old_status TEXT, new_status TEXT,
|
|
old_note TEXT NOT NULL DEFAULT '', new_note TEXT NOT NULL DEFAULT ''
|
|
)''')
|
|
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_events ON ticket_events(ticket_id,id)')
|
|
# A legacy baseline records only the known state at upgrade, never invented actions.
|
|
connection.execute('''UPDATE tickets SET claimed_at=updated_at,claimed_at_estimated=1
|
|
WHERE status='processing' AND claimed_at IS NULL AND datetime(updated_at,'+0 seconds')=updated_at''')
|
|
connection.execute('''INSERT INTO ticket_events(ticket_id,action,actor_name,ctime,new_status,new_note)
|
|
SELECT id,'legacy_snapshot','系统',?,status,COALESCE(note,'') FROM tickets
|
|
WHERE NOT EXISTS(SELECT 1 FROM ticket_events e WHERE e.ticket_id=tickets.id)''',
|
|
(datetime.now().strftime('%Y-%m-%d %H:%M:%S'),))
|
|
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_pending_time ON tickets(status,ctime)')
|
|
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_claimed_time ON tickets(status,claimed_at)')
|
|
|
|
|
|
def record_event(connection, ticket_id, action, now, staff=None, old_status=None,
|
|
new_status=None, old_note='', new_note=''):
|
|
connection.execute('''INSERT INTO ticket_events(ticket_id,action,actor_username,actor_name,ctime,
|
|
old_status,new_status,old_note,new_note) VALUES(?,?,?,?,?,?,?,?,?)''',
|
|
(ticket_id, action, staff['username'] if staff else None, staff['name'] if staff else '客户',
|
|
now, old_status, new_status, old_note, new_note))
|
|
|
|
|
|
def create_ticket(content, phone):
|
|
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
with closing(connect()) as connection:
|
|
cursor = connection.execute('INSERT INTO tickets(content,phone,ctime,status) VALUES(?,?,?,?)',
|
|
(content, phone, now, 'pending'))
|
|
record_event(connection, cursor.lastrowid, 'submitted', now, new_status='pending')
|
|
connection.commit()
|
|
return {'ok': True}
|
|
|
|
|
|
def _with_overdue(ticket, now=None):
|
|
ticket.update(overdue_kind=None, overdue_minutes=0)
|
|
anchor = ticket['ctime'] if ticket['status'] == 'pending' else ticket['claimed_at']
|
|
threshold = PENDING_TIMEOUT if ticket['status'] == 'pending' else PROCESSING_TIMEOUT
|
|
if ticket['status'] not in ('pending', 'processing') or not anchor:
|
|
return ticket
|
|
try:
|
|
parsed = DateTime.fromisoformat(anchor)
|
|
if parsed.strftime('%Y-%m-%d %H:%M:%S') != anchor:
|
|
return ticket
|
|
elapsed = ((now or datetime.now()).replace(microsecond=0) - parsed).total_seconds()
|
|
except (ValueError, TypeError):
|
|
return ticket
|
|
if elapsed > threshold:
|
|
ticket.update(overdue_kind=ticket['status'], overdue_minutes=max(1,ceil((elapsed-threshold)/60)))
|
|
return ticket
|
|
|
|
|
|
def _overdue_predicate():
|
|
return "((status='pending' AND datetime(ctime,'+0 seconds')=ctime AND ctime<?) OR " \
|
|
"(status='processing' AND datetime(claimed_at,'+0 seconds')=claimed_at AND claimed_at<?))"
|
|
|
|
|
|
def _cutoffs(now):
|
|
return tuple((now-timedelta(seconds=seconds)).strftime('%Y-%m-%d %H:%M:%S')
|
|
for seconds in (PENDING_TIMEOUT, PROCESSING_TIMEOUT))
|
|
|
|
|
|
def _ticket(connection, ticket_id):
|
|
if not 0 < ticket_id <= MAX_TICKET_ID:
|
|
return None
|
|
row = connection.execute('SELECT ' + ','.join(COLUMNS) + ' FROM tickets WHERE id=?', (ticket_id,)).fetchone()
|
|
return _with_overdue(dict(zip(COLUMNS, row))) if row else None
|
|
|
|
|
|
def get_ticket(ticket_id):
|
|
with closing(connect()) as connection:
|
|
ticket = _ticket(connection, ticket_id)
|
|
return {'ok': True, 'ticket': ticket} if ticket else ({'ok': False, 'error':'留言不存在。'}, 404)
|
|
|
|
|
|
def list_tickets(status='pending', limit=20, before=None, query='', overdue='0'):
|
|
if overdue not in ('0','1'):
|
|
return {'ok': False, 'error':'超时筛选参数无效。'}, 400
|
|
if not isinstance(query, str) or len(query) > 100:
|
|
return {'ok': False, 'error':'搜索关键词最多 100 字。'}, 400
|
|
if status not in (*STATES, 'all'):
|
|
return {'ok': False, 'error':'留言状态无效。'}, 400
|
|
try:
|
|
limit = int(limit)
|
|
before = int(before) if before is not None else None
|
|
except (ValueError, TypeError):
|
|
return {'ok': False, 'error':'分页参数无效。'}, 400
|
|
if not 1 <= limit <= 100 or (before is not None and not 0 < before <= MAX_TICKET_ID):
|
|
return {'ok': False, 'error':'分页参数无效。'}, 400
|
|
clauses, parameters = [], []
|
|
now = datetime.now()
|
|
if overdue == '1':
|
|
clauses.append(_overdue_predicate())
|
|
parameters.extend(_cutoffs(now))
|
|
if status != 'all':
|
|
clauses.append('status=?')
|
|
parameters.append(status)
|
|
if before is not None:
|
|
clauses.append('id<?')
|
|
parameters.append(before)
|
|
if query.strip():
|
|
literal = query.strip().replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
|
|
fields = ('content', 'phone', 'owner_username', 'owner_name', 'note')
|
|
clauses.append('(' + ' OR '.join(f"{field} LIKE ? ESCAPE '\\'" for field in fields) + ')')
|
|
parameters.extend(['%' + literal + '%'] * len(fields))
|
|
where = ' WHERE ' + ' AND '.join(clauses) if clauses else ''
|
|
with closing(connect()) as connection:
|
|
rows = connection.execute('SELECT ' + ','.join(COLUMNS) + ' FROM tickets' + where +
|
|
' ORDER BY id DESC LIMIT ?', (*parameters, limit)).fetchall()
|
|
return [_with_overdue(dict(zip(COLUMNS, row)), now) for row in rows]
|
|
|
|
|
|
def ticket_summary(after=None):
|
|
if after is not None:
|
|
try:
|
|
after = int(after)
|
|
except (TypeError, ValueError):
|
|
return {'ok': False, 'error':'留言提醒游标无效。'}, 400
|
|
if not 0 <= after <= MAX_TICKET_ID:
|
|
return {'ok': False, 'error':'留言提醒游标无效。'}, 400
|
|
# One snapshot, independent of the list's status, search and page cursor.
|
|
pending_cutoff, processing_cutoff = _cutoffs(datetime.now())
|
|
with closing(connect()) as connection:
|
|
pending, latest, new, pending_overdue, processing_overdue = connection.execute(
|
|
"SELECT (SELECT COUNT(*) FROM tickets WHERE status='pending'),"
|
|
'(SELECT COALESCE(MAX(id),0) FROM tickets),'
|
|
'(SELECT COUNT(*) FROM tickets WHERE id>?),'
|
|
"(SELECT COUNT(*) FROM tickets WHERE status='pending' AND datetime(ctime,'+0 seconds')=ctime AND ctime<?),"
|
|
"(SELECT COUNT(*) FROM tickets WHERE status='processing' AND datetime(claimed_at,'+0 seconds')=claimed_at AND claimed_at<?)",
|
|
(after,pending_cutoff,processing_cutoff)).fetchone()
|
|
return {'pending_count':pending, 'latest_id':latest, 'new_count':new,
|
|
'overdue_count':pending_overdue+processing_overdue,
|
|
'pending_overdue_count':pending_overdue, 'processing_overdue_count':processing_overdue}
|
|
|
|
|
|
def ticket_events(ticket_id, limit=20, before=None):
|
|
try:
|
|
limit = int(limit)
|
|
before = int(before) if before is not None else None
|
|
except (ValueError, TypeError):
|
|
return {'ok':False, 'error':'记录分页参数无效。'}, 400
|
|
if not 1 <= limit <= 100 or (before is not None and not 0 < before <= MAX_TICKET_ID):
|
|
return {'ok':False, 'error':'记录分页参数无效。'}, 400
|
|
with closing(connect()) as connection:
|
|
if not _ticket(connection,ticket_id):
|
|
return {'ok':False, 'error':'留言不存在。'}, 404
|
|
rows = connection.execute('SELECT '+','.join(EVENT_COLUMNS)+' FROM ticket_events WHERE ticket_id=?'+
|
|
(' AND id<?' if before is not None else '')+' ORDER BY id DESC LIMIT ?',
|
|
(ticket_id, before, limit) if before is not None else (ticket_id,limit)).fetchall()
|
|
return [dict(zip(EVENT_COLUMNS,row)) for row in rows]
|
|
|
|
|
|
def claim_ticket(ticket_id, staff):
|
|
if not 0 < ticket_id <= MAX_TICKET_ID:
|
|
return {'ok': False, 'error':'留言不存在。'}, 404
|
|
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
with closing(connect()) as connection:
|
|
# The guarded write makes simultaneous claims select exactly one owner.
|
|
changed = connection.execute("UPDATE tickets SET status='processing',owner_username=?,"
|
|
"owner_name=?,updated_at=?,claimed_at=?,claimed_at_estimated=0,version=version+1 WHERE id=? AND status='pending' "
|
|
'AND owner_username IS NULL', (staff['username'], staff['name'], now, now, ticket_id)).rowcount
|
|
ticket = _ticket(connection, ticket_id)
|
|
if not changed:
|
|
return ({'ok': False, 'error':'留言已被领取或已完成,请刷新查看。'}, 409) if ticket else (
|
|
{'ok': False, 'error':'留言不存在。'}, 404)
|
|
record_event(connection,ticket_id,'claimed',now,staff,old_status='pending',new_status='processing')
|
|
connection.commit()
|
|
return {'ok': True, 'ticket': ticket}
|
|
|
|
|
|
def update_ticket(ticket_id, data, staff):
|
|
if (not isinstance(data, dict) or set(data) - {'version', 'note', 'status'}
|
|
or type(data.get('version')) is not int or data['version'] < 0
|
|
or not ({'note', 'status'} & set(data))):
|
|
return {'ok': False, 'error':'留言更新格式无效,请刷新后重试。'}, 400
|
|
if 'note' in data and (not isinstance(data['note'], str) or len(data['note']) > 2000):
|
|
return {'ok': False, 'error':'处理备注最多 2000 字。'}, 400
|
|
status = data.get('status', 'processing')
|
|
if status not in ('processing', 'completed'):
|
|
return {'ok': False, 'error':'处理状态无效。'}, 400
|
|
with closing(connect()) as connection:
|
|
connection.execute('BEGIN IMMEDIATE')
|
|
ticket = _ticket(connection, ticket_id)
|
|
if not ticket:
|
|
return {'ok': False, 'error':'留言不存在。'}, 404
|
|
if ticket['status'] != 'processing':
|
|
return {'ok': False, 'error':'请先领取留言,已完成留言不能修改。'}, 409
|
|
if ticket['owner_username'] != staff['username']:
|
|
return {'ok': False, 'error':'只有负责人可以处理这条留言。'}, 403
|
|
if ticket['version'] != data['version']:
|
|
return {'ok': False, 'error':'留言已在其他页面更新,请刷新后核对备注。'}, 409
|
|
note = data.get('note', ticket['note']).strip()
|
|
if status == 'completed' and not note:
|
|
return {'ok': False, 'error':'请先填写处理结果,再标记完成。'}, 400
|
|
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
connection.execute('UPDATE tickets SET note=?,status=?,updated_at=?,completed_at=?,version=version+1 '
|
|
'WHERE id=?', (note, status, now, now if status == 'completed' else None, ticket_id))
|
|
if note != ticket['note']:
|
|
record_event(connection,ticket_id,'note_updated',now,staff,old_note=ticket['note'],new_note=note)
|
|
if status == 'completed':
|
|
record_event(connection,ticket_id,'completed',now,staff,old_status='processing',new_status='completed',
|
|
new_note=note)
|
|
ticket = _ticket(connection, ticket_id)
|
|
connection.commit()
|
|
return {'ok': True, 'ticket': ticket}
|