489 lines
21 KiB
Python
489 lines
21 KiB
Python
# -*- coding: utf-8 -*-
|
||
import json
|
||
from odoo import http, fields
|
||
from odoo.http import request, Response
|
||
|
||
|
||
def _json(data):
|
||
"""返回 UTF-8 JSON 响应,并附加本地跨域调试头。"""
|
||
origin = request.httprequest.headers.get('Origin', '')
|
||
resp = Response(
|
||
json.dumps(data, ensure_ascii=False),
|
||
content_type='application/json; charset=utf-8',
|
||
)
|
||
# 允许 127.0.0.1:8888 本地前端跨域调试(生产环境走 nginx 同域,无需此头)
|
||
if origin.startswith('http://127.0.0.1:') or origin.startswith('http://localhost:'):
|
||
resp.headers['Access-Control-Allow-Origin'] = origin
|
||
resp.headers['Access-Control-Allow-Credentials'] = 'true'
|
||
resp.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
|
||
resp.headers['Access-Control-Allow-Headers'] = 'Content-Type'
|
||
return resp
|
||
|
||
|
||
def _post_body():
|
||
try:
|
||
raw = request.httprequest.get_data(as_text=True) or '{}'
|
||
return json.loads(raw)
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
class GameApiController(http.Controller):
|
||
|
||
# ============ 公开只读数据接口(前端从 Odoo 取数) ============
|
||
|
||
@http.route('/game/api/codex', type='http', auth='public', methods=['GET'], csrf=False)
|
||
def codex(self, **kw):
|
||
"""图鉴:按分类(cat)分组,每组内按子级(sub)分组。"""
|
||
model = request.env['game.codex.entry'].sudo()
|
||
cat_labels = dict(model._fields['cat'].selection)
|
||
recs = model.search_read([], ['id', 'cat', 'sub', 'name', 'tier', 'desc'])
|
||
by_cat = {}
|
||
for r in recs:
|
||
by_cat.setdefault(r['cat'], []).append(r)
|
||
groups = []
|
||
for cat in by_cat:
|
||
subs = {}
|
||
for r in by_cat[cat]:
|
||
sub = r['sub'] or '其他'
|
||
subs.setdefault(sub, []).append({
|
||
'id': r['id'],
|
||
'name': r['name'],
|
||
'tier': r['tier'],
|
||
'sub': r['sub'] or '',
|
||
'desc': r['desc'] or '',
|
||
'image': '/web/image/game.codex.entry/%s/image' % r['id'],
|
||
})
|
||
groups.append({
|
||
'key': cat,
|
||
'label': cat_labels.get(cat, cat),
|
||
'subs': [{'name': s, 'items': subs[s]} for s in subs],
|
||
})
|
||
return _json({'groups': groups})
|
||
|
||
@http.route('/game/api/rules', type='http', auth='public', methods=['GET'], csrf=False)
|
||
def rules(self, **kw):
|
||
"""规则 / 等级制度 / 货币:按类别分组。"""
|
||
Rule = request.env['game.rule'].sudo()
|
||
Category = request.env['game.rule.category'].sudo()
|
||
cats = Category.search_read(
|
||
[('active', '=', True)],
|
||
['id', 'key', 'name', 'sequence'],
|
||
order='sequence, id'
|
||
)
|
||
cat_map = {c['id']: c for c in cats}
|
||
recs = Rule.search_read(
|
||
[],
|
||
['id', 'category_id', 'level', 'name', 'desc', 'rate', 'note'],
|
||
order='category_id, level'
|
||
)
|
||
by_cat = {}
|
||
for r in recs:
|
||
cid = r['category_id'][0] if r['category_id'] else None
|
||
if cid not in cat_map:
|
||
continue
|
||
by_cat.setdefault(cid, []).append(r)
|
||
groups = []
|
||
for c in cats:
|
||
items = by_cat.get(c['id'], [])
|
||
if not items:
|
||
continue
|
||
groups.append({
|
||
'key': c['key'],
|
||
'label': c['name'],
|
||
'items': [{
|
||
'id': it['id'],
|
||
'level': it['level'],
|
||
'name': it['name'],
|
||
'desc': it['desc'] or '',
|
||
'rate': it.get('rate') or 1,
|
||
'note': it.get('note') or '',
|
||
} for it in items],
|
||
})
|
||
return _json({'groups': groups})
|
||
|
||
@http.route('/game/api/currencies', type='http', auth='public', methods=['GET'], csrf=False)
|
||
def currencies(self, **kw):
|
||
"""货币等级:兼容旧接口,实际从 game.rule 按类别过滤。"""
|
||
Rule = request.env['game.rule'].sudo()
|
||
Category = request.env['game.rule.category'].sudo()
|
||
currency_keys = ['currency', 'material', 'token', 'honor']
|
||
cats = Category.search_read(
|
||
[('active', '=', True), ('key', 'in', currency_keys)],
|
||
['id', 'key', 'name', 'sequence'],
|
||
order='sequence, id'
|
||
)
|
||
cat_map = {c['id']: c for c in cats}
|
||
recs = Rule.search_read(
|
||
[('category_id.key', 'in', currency_keys)],
|
||
['id', 'category_id', 'level', 'name', 'desc', 'note', 'rate'],
|
||
order='category_id, level'
|
||
)
|
||
by_type = {}
|
||
for r in recs:
|
||
cid = r['category_id'][0] if r['category_id'] else None
|
||
if cid not in cat_map:
|
||
continue
|
||
by_type.setdefault(cid, []).append(r)
|
||
groups = []
|
||
for c in cats:
|
||
items = by_type.get(c['id'], [])
|
||
if not items:
|
||
continue
|
||
groups.append({
|
||
'key': c['key'],
|
||
'label': c['name'],
|
||
'items': [{
|
||
'id': it['id'],
|
||
'level': it['level'],
|
||
'name': it['name'],
|
||
'desc': it['desc'] or '',
|
||
'note': it.get('note') or '',
|
||
'rate': it.get('rate') or 1,
|
||
} for it in items],
|
||
})
|
||
return _json({'groups': groups})
|
||
|
||
@http.route('/game/api/pages', type='http', auth='public', methods=['GET'], csrf=False)
|
||
def pages(self, **kw):
|
||
"""页面板块:返回所有生效板块及其条目。"""
|
||
pages = request.env['game.page'].sudo().search_read(
|
||
[('active', '=', True)],
|
||
['id', 'key', 'title', 'subtitle', 'body_html'],
|
||
order='sequence asc'
|
||
)
|
||
out = []
|
||
for p in pages:
|
||
items = request.env['game.page.item'].sudo().search_read(
|
||
[('page_id', '=', p['id'])],
|
||
['id', 'icon', 'title', 'desc'],
|
||
order='sequence asc'
|
||
)
|
||
out.append({
|
||
'id': p['id'],
|
||
'key': p['key'],
|
||
'title': p['title'],
|
||
'subtitle': p.get('subtitle') or '',
|
||
'body_html': p.get('body_html') or '',
|
||
'items': [{
|
||
'id': it['id'],
|
||
'icon': it.get('icon') or '',
|
||
'title': it.get('title') or '',
|
||
'desc': it.get('desc') or '',
|
||
} for it in items],
|
||
})
|
||
return _json({'items': out})
|
||
|
||
@http.route('/game/api/threads', type='http', auth='public', methods=['GET'], csrf=False)
|
||
def threads(self, **kw):
|
||
"""论坛主题(含回复),按置顶+时间排序。"""
|
||
model = request.env['game.forum.thread'].sudo()
|
||
cat_labels = dict(model._fields['category'].selection)
|
||
recs = model.search_read(
|
||
[], ['id', 'name', 'category', 'author_name', 'pinned', 'create_date', 'body_html'])
|
||
out = []
|
||
for r in recs:
|
||
posts = request.env['game.forum.post'].sudo().search_read(
|
||
[('thread_id', '=', r['id'])],
|
||
['id', 'author_name', 'body_html', 'create_date'])
|
||
out.append({
|
||
'id': r['id'],
|
||
'name': r['name'],
|
||
'category': r['category'],
|
||
'category_label': cat_labels.get(r['category'], r['category']),
|
||
'author_name': r['author_name'] or '',
|
||
'pinned': r['pinned'],
|
||
'create_date': str(r['create_date']),
|
||
'body_html': r['body_html'] or '',
|
||
'replies': posts,
|
||
})
|
||
return _json(out)
|
||
|
||
@http.route('/game/api/announcements', type='http', auth='public', methods=['GET'], csrf=False)
|
||
def announcements(self, **kw):
|
||
"""公告(仅生效的)。"""
|
||
model = request.env['game.announcement'].sudo()
|
||
pri_labels = dict(model._fields['priority'].selection)
|
||
recs = model.search_read(
|
||
[('active', '=', True)], ['id', 'name', 'date', 'priority', 'body_html'])
|
||
out = [{
|
||
'id': r['id'],
|
||
'name': r['name'],
|
||
'date': str(r['date']),
|
||
'priority': r['priority'],
|
||
'priority_label': pri_labels.get(r['priority'], r['priority']),
|
||
'body_html': r['body_html'] or '',
|
||
} for r in recs]
|
||
return _json(out)
|
||
|
||
@http.route('/game/api/story', type='http', auth='public', methods=['GET'], csrf=False)
|
||
def story(self, **kw):
|
||
"""小说目录:返回 卷 -> 章节 结构,供前端左侧目录(TOC)使用。"""
|
||
Volume = request.env['game.story.volume'].sudo()
|
||
Chapter = request.env['game.story.chapter'].sudo()
|
||
vols = Volume.search_read([], ['id', 'name', 'sequence', 'intro'], order='sequence, id')
|
||
chapters = Chapter.search_read([], ['id', 'volume_id', 'name', 'sequence'],
|
||
order='volume_id, sequence, id')
|
||
by_vol = {}
|
||
for c in chapters:
|
||
vid = c['volume_id'][0] if c['volume_id'] else None
|
||
by_vol.setdefault(vid, []).append({
|
||
'id': c['id'],
|
||
'name': c['name'],
|
||
'sequence': c['sequence'],
|
||
})
|
||
out = []
|
||
for v in vols:
|
||
out.append({
|
||
'id': v['id'],
|
||
'name': v['name'],
|
||
'sequence': v['sequence'],
|
||
'intro': v.get('intro') or '',
|
||
'chapters': by_vol.get(v['id'], []),
|
||
})
|
||
return _json({'volumes': out})
|
||
|
||
@http.route('/game/api/story/chapter/<int:cid>', type='http', auth='public', methods=['GET'], csrf=False)
|
||
def story_chapter(self, cid, **kw):
|
||
"""单章正文(点击目录时按需拉取,避免一次性下发整本小说)。"""
|
||
rec = request.env['game.story.chapter'].sudo().search_read(
|
||
[('id', '=', cid)], ['id', 'volume_id', 'name', 'sequence', 'body_html'])
|
||
if not rec:
|
||
return _json({'error': '章节不存在'})
|
||
r = rec[0]
|
||
return _json({
|
||
'id': r['id'],
|
||
'volume_id': r['volume_id'][0] if r['volume_id'] else None,
|
||
'name': r['name'],
|
||
'sequence': r['sequence'],
|
||
'body_html': r['body_html'] or '',
|
||
})
|
||
|
||
# ============ 登录 / 注册 / 会话(与 Odoo res.users 打通) ============
|
||
|
||
@http.route('/game/api/login', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def login(self, **kw):
|
||
data = _post_body()
|
||
db = data.get('db') or request.session.db or 'game'
|
||
login = (data.get('login') or '').strip()
|
||
password = data.get('password') or ''
|
||
if not login or not password:
|
||
return _json({'ok': False, 'error': '账号和密码必填'})
|
||
try:
|
||
auth_info = request.session.authenticate(db, {'type': 'password', 'login': login, 'password': password})
|
||
uid = auth_info['uid']
|
||
except Exception:
|
||
return _json({'ok': False, 'error': '账号或密码错误'})
|
||
if uid:
|
||
user = request.env['res.users'].sudo().browse(uid)
|
||
return _json({'ok': True, 'uid': uid, 'name': user.name, 'login': user.login})
|
||
return _json({'ok': False, 'error': '账号或密码错误'})
|
||
|
||
@http.route('/game/api/register', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def register(self, **kw):
|
||
data = _post_body()
|
||
name = (data.get('name') or '').strip()
|
||
login = (data.get('login') or '').strip()
|
||
email = (data.get('email') or '').strip()
|
||
password = data.get('password') or ''
|
||
if not login or not password:
|
||
return _json({'ok': False, 'error': '账号和密码必填'})
|
||
if len(password) < 8:
|
||
return _json({'ok': False, 'error': '密码至少 8 位'})
|
||
if not any(c.isalpha() for c in password) or not any(c.isdigit() for c in password):
|
||
return _json({'ok': False, 'error': '密码必须同时包含字母和数字'})
|
||
User = request.env['res.users'].sudo()
|
||
if User.search([('login', '=', login)]):
|
||
return _json({'ok': False, 'error': '该账号已存在'})
|
||
if name and User.search([('name', '=', name)]):
|
||
return _json({'ok': False, 'error': '该昵称已被使用'})
|
||
try:
|
||
portal = request.env.ref('base.group_portal')
|
||
user = User.create({
|
||
'name': name or login,
|
||
'login': login,
|
||
'email': email or False,
|
||
'password': password,
|
||
'groups_id': [(6, 0, [portal.id])],
|
||
})
|
||
request.env.cr.commit() # 让新用户立即对其他游标可见,便于随后自动登录
|
||
# === 自动创建 hr.employee 并初始化游戏数据 ===
|
||
Emp = request.env['hr.employee'].sudo()
|
||
emp = Emp.create({
|
||
'name': name or login,
|
||
'user_id': user.id,
|
||
'work_email': email or False,
|
||
'game_title': '萌新',
|
||
'game_level': 1,
|
||
'game_exp': 0,
|
||
'game_exp_max': 100,
|
||
'game_coins': 0,
|
||
'game_energy': 0,
|
||
'game_wood': 0,
|
||
'game_stone': 0,
|
||
'game_reg_date': fields.Date.today(),
|
||
})
|
||
# 生成游戏 UID(格式 10086 + 4位员工ID)
|
||
emp.game_uid = '10086%04d' % emp.id
|
||
# 给新玩家解锁第一个成就"开天辟地"
|
||
tmpl = request.env['game.achievement.template'].sudo().search(
|
||
[('code', '=', 'first_land')], limit=1)
|
||
if tmpl:
|
||
request.env['game.player.achievement'].sudo().create({
|
||
'employee_id': emp.id,
|
||
'template_id': tmpl.id,
|
||
'progress': tmpl.target,
|
||
'unlocked': True,
|
||
'unlock_date': fields.Datetime.now(),
|
||
})
|
||
# 给新玩家发一封欢迎邮件
|
||
request.env['game.player.mail'].sudo().create({
|
||
'employee_id': emp.id,
|
||
'category': 'system',
|
||
'subject': '欢迎来到宇森',
|
||
'sender': '系统',
|
||
'body': '<p>欢迎来到宇森!请查收新手礼包与世界探索指南。</p>',
|
||
})
|
||
request.env.cr.commit()
|
||
db = request.session.db or 'game'
|
||
auth_info = request.session.authenticate(db, {'type': 'password', 'login': login, 'password': password})
|
||
uid = auth_info['uid']
|
||
if uid:
|
||
return _json({'ok': True, 'uid': uid, 'name': user.name, 'login': user.login,
|
||
'emp_id': emp.id, 'game_uid': emp.game_uid})
|
||
return _json({'ok': True, 'uid': user.id, 'name': user.name, 'login': user.login,
|
||
'emp_id': emp.id, 'game_uid': emp.game_uid})
|
||
except Exception as e:
|
||
return _json({'ok': False, 'error': '注册失败:%s' % str(e)})
|
||
|
||
@http.route('/game/api/me', type='http', auth='public', methods=['GET'], csrf=False)
|
||
def me(self, **kw):
|
||
uid = request.session.uid
|
||
if uid:
|
||
user = request.env['res.users'].sudo().browse(uid)
|
||
if user.exists():
|
||
data = {'uid': uid, 'name': user.name, 'login': user.login,
|
||
'email': user.email or ''}
|
||
emp = request.env['hr.employee'].sudo().search(
|
||
[('user_id', '=', uid)], limit=1)
|
||
if emp:
|
||
data.update(emp.export_game_data())
|
||
return _json(data)
|
||
return _json({'uid': None})
|
||
|
||
@http.route('/game/api/achievements', type='http', auth='public',
|
||
methods=['GET'], csrf=False)
|
||
def achievements(self, **kw):
|
||
"""返回当前登录玩家的成就列表(含模板+进度)。"""
|
||
uid = request.session.uid
|
||
if not uid:
|
||
return _json({'error': '未登录'})
|
||
emp = request.env['hr.employee'].sudo().search(
|
||
[('user_id', '=', uid)], limit=1)
|
||
if not emp:
|
||
return _json({'error': '玩家不存在', 'items': [], 'total_points': 0})
|
||
# 取所有启用的成就模板
|
||
tmpl_model = request.env['game.achievement.template'].sudo()
|
||
tier_labels = dict(tmpl_model._fields['tier'].selection)
|
||
templates = tmpl_model.search_read(
|
||
[('active', '=', True)],
|
||
['id', 'name', 'icon', 'tier', 'desc', 'points', 'target', 'sequence'],
|
||
order='sequence, id')
|
||
# 取该玩家已解锁的成就
|
||
player_recs = request.env['game.player.achievement'].sudo().search_read(
|
||
[('employee_id', '=', emp.id)],
|
||
['template_id', 'progress', 'unlocked', 'unlock_date'])
|
||
progress_map = {
|
||
r['template_id'][0]: r for r in player_recs
|
||
}
|
||
items = []
|
||
total_points = 0
|
||
unlocked_count = 0
|
||
for t in templates:
|
||
pr = progress_map.get(t['id'])
|
||
unlocked = pr and pr['unlocked']
|
||
progress = pr['progress'] if pr else 0
|
||
if unlocked:
|
||
total_points += t['points']
|
||
unlocked_count += 1
|
||
items.append({
|
||
'id': t['id'],
|
||
'name': t['name'],
|
||
'icon': t.get('icon') or '',
|
||
'tier': t['tier'],
|
||
'tier_label': tier_labels.get(t['tier'], t['tier']),
|
||
'desc': t.get('desc') or '',
|
||
'points': t['points'],
|
||
'target': t['target'],
|
||
'progress': progress,
|
||
'unlocked': bool(unlocked),
|
||
'unlock_date': str(pr['unlock_date']) if pr and pr['unlock_date'] else None,
|
||
})
|
||
return _json({
|
||
'items': items,
|
||
'total_points': total_points,
|
||
'unlocked_count': unlocked_count,
|
||
'total_count': len(items),
|
||
})
|
||
|
||
@http.route('/game/api/mails', type='http', auth='public',
|
||
methods=['GET'], csrf=False)
|
||
def mails(self, **kw):
|
||
"""返回当前登录玩家的邮件列表。"""
|
||
uid = request.session.uid
|
||
if not uid:
|
||
return _json({'error': '未登录', 'items': []})
|
||
emp = request.env['hr.employee'].sudo().search(
|
||
[('user_id', '=', uid)], limit=1)
|
||
if not emp:
|
||
return _json({'error': '玩家不存在', 'items': []})
|
||
cat_labels = dict(
|
||
request.env['game.player.mail'].sudo()._fields['category'].selection)
|
||
recs = request.env['game.player.mail'].sudo().search_read(
|
||
[('employee_id', '=', emp.id)],
|
||
['id', 'category', 'subject', 'body', 'date', 'is_read',
|
||
'is_pinned', 'sender'],
|
||
order='is_pinned desc, date desc')
|
||
items = [{
|
||
'id': r['id'],
|
||
'category': r['category'],
|
||
'category_label': cat_labels.get(r['category'], r['category']),
|
||
'subject': r['subject'],
|
||
'body': r.get('body') or '',
|
||
'date': str(r['date']) if r['date'] else '',
|
||
'is_read': r['is_read'],
|
||
'is_pinned': r['is_pinned'],
|
||
'sender': r.get('sender') or '',
|
||
} for r in recs]
|
||
unread = len([r for r in items if not r['is_read']])
|
||
return _json({'items': items, 'unread': unread})
|
||
|
||
@http.route('/game/api/mail/read', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def mail_read(self, **kw):
|
||
"""标记邮件为已读。"""
|
||
uid = request.session.uid
|
||
if not uid:
|
||
return _json({'ok': False, 'error': '未登录'})
|
||
data = _post_body()
|
||
mail_id = data.get('mail_id')
|
||
if not mail_id:
|
||
return _json({'ok': False, 'error': '缺少 mail_id'})
|
||
emp = request.env['hr.employee'].sudo().search(
|
||
[('user_id', '=', uid)], limit=1)
|
||
if not emp:
|
||
return _json({'ok': False, 'error': '玩家不存在'})
|
||
mail = request.env['game.player.mail'].sudo().browse(mail_id)
|
||
if not mail.exists() or mail.employee_id != emp:
|
||
return _json({'ok': False, 'error': '邮件不存在或无权操作'})
|
||
mail.write({'is_read': True})
|
||
return _json({'ok': True})
|
||
|
||
@http.route('/game/api/logout', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def logout(self, **kw):
|
||
request.session.logout()
|
||
return _json({'ok': True})
|